diff --git a/.esprintrc b/.esprintrc
new file mode 100644
index 000000000..9330e00d1
--- /dev/null
+++ b/.esprintrc
@@ -0,0 +1,9 @@
+{
+ "paths": [
+ "frontend/src/**/*.js"
+ ],
+ "ignored": [
+ "**/node_modules/**/*"
+ ],
+ "port": 5004
+}
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..ad5884817
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+save-prefix=""
diff --git a/.yarnrc b/.yarnrc
new file mode 100644
index 000000000..fdd705c63
--- /dev/null
+++ b/.yarnrc
@@ -0,0 +1 @@
+save-prefix ""
diff --git a/build.sh b/build.sh
index 77526e0fb..cede3c535 100755
--- a/build.sh
+++ b/build.sh
@@ -90,16 +90,16 @@ Build()
RunGulp()
{
- echo "##teamcity[progressStart 'npm install']"
- npm-cache install npm || CheckExitCode npm install --no-optional --no-bin-links
- echo "##teamcity[progressFinish 'npm install']"
+ ProgressStart 'yarn install'
+ yarn install
+ ProgressEnd 'yarn install'
echo "##teamcity[progressStart 'Running gulp']"
CheckExitCode npm run build
echo "##teamcity[progressFinish 'Running gulp']"
echo "##teamcity[progressStart 'Running gulp (phantom)']"
- CheckExitCode npm run build -- --phantom --production
+ CheckExitCode yarn run build -- --production
echo "##teamcity[progressFinish 'Running gulp (phantom)']"
}
diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json
new file mode 100644
index 000000000..a82e49732
--- /dev/null
+++ b/frontend/.csscomb.json
@@ -0,0 +1,25 @@
+{
+ "remove-empty-rulesets": true,
+ "always-semicolon": true,
+ "color-case": "lower",
+ "block-indent": " ",
+ "color-shorthand": false,
+ "element-case": "lower",
+ "eof-newline": true,
+ "leading-zero": true,
+ "quotes": "double",
+ "sort-order-fallback": "abc",
+ "space-before-colon": "",
+ "space-after-colon": " ",
+ "space-before-combinator": " ",
+ "space-after-combinator": " ",
+ "space-between-declarations": "\n",
+ "space-before-opening-brace": " ",
+ "space-after-opening-brace": "\n",
+ "space-after-selector-delimiter": " ",
+ "space-before-selector-delimiter": "",
+ "space-before-closing-brace": "\n",
+ "strip-spaces": true,
+ "tab-size": true,
+ "unitless-zero": false
+}
diff --git a/frontend/.esformatter b/frontend/.esformatter
new file mode 100644
index 000000000..600bb0751
--- /dev/null
+++ b/frontend/.esformatter
@@ -0,0 +1,335 @@
+{
+ "indent": {
+ "value": " ",
+ "FunctionExpression": 1,
+ "ArrayExpression": 1,
+ "ObjectExpression": 1
+ },
+ "lineBreak": {
+ "value": "\n",
+
+ "before": {
+ "ArrayPatternClosing": 0,
+ "ArrayPatternComma": 0,
+ "ArrayPatternOpening": 0,
+ "ArrowFunctionExpressionArrow": 0,
+ "ArrowFunctionExpressionClosingBrace": ">=1",
+ "ArrowFunctionExpressionOpeningBrace": 0,
+ "AssignmentExpression": ">=1",
+ "AssignmentOperator": 0,
+ "BlockStatement": 0,
+ "BreakKeyword": ">=1",
+ "CallExpression": -1,
+ "CallExpressionClosingParentheses": -1,
+ "CallExpressionOpeningParentheses": 0,
+ "CatchClosingBrace": ">=1",
+ "CatchKeyword": 0,
+ "CatchOpeningBrace": 0,
+ "ClassDeclaration": ">=1",
+ "ClassDeclarationClosingBrace": ">=1",
+ "ClassDeclarationOpeningBrace": 0,
+ "ConditionalExpression": ">=1",
+ "DeleteOperator": ">=1",
+ "DoWhileStatement": ">=1",
+ "DoWhileStatementClosingBrace": ">=1",
+ "DoWhileStatementOpeningBrace": 0,
+ "ElseIfStatement": 0,
+ "ElseIfStatementClosingBrace": ">=1",
+ "ElseIfStatementOpeningBrace": 0,
+ "ElseStatement": 0,
+ "ElseStatementClosingBrace": ">=1",
+ "ElseStatementOpeningBrace": 0,
+ "EmptyStatement": -1,
+ "EndOfFile": -1,
+ "FinallyClosingBrace": ">=1",
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": 0,
+ "ForInStatement": ">=1",
+ "ForInStatementClosingBrace": ">=1",
+ "ForInStatementExpressionClosing": 0,
+ "ForInStatementExpressionOpening": 0,
+ "ForInStatementOpeningBrace": 0,
+ "ForStatement": ">=1",
+ "ForStatementClosingBrace": ">=1",
+ "ForStatementExpressionClosing": "<2",
+ "ForStatementExpressionOpening": 0,
+ "ForStatementOpeningBrace": 0,
+ "FunctionDeclaration": ">=1",
+ "FunctionDeclarationClosingBrace": ">=1",
+ "FunctionDeclarationOpeningBrace": 0,
+ "FunctionExpression": 0,
+ "FunctionExpressionClosingBrace": 1,
+ "FunctionExpressionOpeningBrace":0,
+ "IIFEClosingParentheses": 0,
+ "IfStatement": ">=1",
+ "IfStatementClosingBrace": ">=1",
+ "IfStatementOpeningBrace": 0,
+ "LogicalExpression": -1,
+ "MemberExpressionClosing": 0,
+ "MemberExpressionOpening": 0,
+ "MemberExpressionPeriod": -1,
+ "MethodDefinition": ">=1",
+ "ObjectExpressionClosingBrace": "<=1",
+ "ObjectPatternClosingBrace": 0,
+ "ObjectPatternComma": 0,
+ "ObjectPatternOpeningBrace": 0,
+ "ParameterDefault": 0,
+ "Property": "<=2",
+ "PropertyValue": 0,
+ "ReturnStatement": -1,
+ "SwitchClosingBrace": ">=1",
+ "SwitchOpeningBrace": 0,
+ "ThisExpression": -1,
+ "ThrowStatement": ">=1",
+ "TryClosingBrace": ">=1",
+ "TryKeyword": -1,
+ "TryOpeningBrace": 0,
+ "VariableDeclaration": ">=1",
+ "VariableDeclarationSemiColon": 0,
+ "VariableDeclarationWithoutInit": ">=1",
+ "VariableName": ">=1",
+ "VariableValue": 0,
+ "WhileStatement": ">=1",
+ "WhileStatementClosingBrace": ">=1",
+ "WhileStatementOpeningBrace": 0
+ },
+
+ "after": {
+ "ArrayPatternClosing": 0,
+ "ArrayPatternComma": 0,
+ "ArrayPatternOpening": 0,
+ "ArrowFunctionExpressionArrow": 0,
+ "ArrowFunctionExpressionClosingBrace": -1,
+ "ArrowFunctionExpressionOpeningBrace": ">=1",
+ "AssignmentExpression": ">=1",
+ "AssignmentOperator": 0,
+ "BlockStatement": 0,
+ "BreakKeyword": -1,
+ "CallExpression": -1,
+ "CallExpressionClosingParentheses": -1,
+ "CallExpressionOpeningParentheses": -1,
+ "CatchClosingBrace": ">=0",
+ "CatchKeyword": 0,
+ "CatchOpeningBrace": ">=1",
+ "ClassDeclaration": ">=1",
+ "ClassDeclarationClosingBrace": ">=1",
+ "ClassDeclarationOpeningBrace": ">=1",
+ "ConditionalExpression": ">=1",
+ "DeleteOperator": ">=1",
+ "DoWhileStatement": ">=1",
+ "DoWhileStatementClosingBrace": 0,
+ "DoWhileStatementOpeningBrace": ">=1",
+ "ElseIfStatement": ">=1",
+ "ElseIfStatementClosingBrace": ">=1",
+ "ElseIfStatementOpeningBrace": ">=1",
+ "ElseStatement": ">=1",
+ "ElseStatementClosingBrace": ">=1",
+ "ElseStatementOpeningBrace": ">=1",
+ "EmptyStatement": -1,
+ "FinallyClosingBrace": ">=1",
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": ">=1",
+ "ForInStatement": ">=1",
+ "ForInStatementClosingBrace": ">=1",
+ "ForInStatementExpressionClosing": -1,
+ "ForInStatementExpressionOpening": "<2",
+ "ForInStatementOpeningBrace": ">=1",
+ "ForStatement": ">=1",
+ "ForStatementClosingBrace": ">=1",
+ "ForStatementExpressionClosing": -1,
+ "ForStatementExpressionOpening": "<2",
+ "ForStatementOpeningBrace": ">=1",
+ "FunctionDeclaration": ">=1",
+ "FunctionDeclarationClosingBrace": ">=1",
+ "FunctionDeclarationOpeningBrace": ">=1",
+ "FunctionExpression": 0,
+ "FunctionExpressionClosingBrace": -1,
+ "FunctionExpressionOpeningBrace": 1,
+ "IIFEOpeningParentheses": 0,
+ "IfStatement": ">=1",
+ "IfStatementClosingBrace": ">=1",
+ "IfStatementOpeningBrace": ">=1",
+ "LogicalExpression": -1,
+ "MemberExpressionClosing": 0,
+ "MemberExpressionOpening": 0,
+ "MemberExpressionPeriod": 0,
+ "MethodDefinition": ">=1",
+ "ObjectExpressionOpeningBrace": "<=1",
+ "ObjectPatternClosingBrace": 0,
+ "ObjectPatternComma": 0,
+ "ObjectPatternOpeningBrace": 0,
+ "ParameterDefault": 0,
+ "Property": -1,
+ "PropertyName": 0,
+ "ReturnStatement": -1,
+ "SwitchCaseColon": ">=1",
+ "SwitchClosingBrace": ">=1",
+ "SwitchOpeningBrace": ">=1",
+ "ThisExpression": 0,
+ "ThrowStatement": ">=1",
+ "TryClosingBrace": 0,
+ "TryKeyword": -1,
+ "TryOpeningBrace": ">=1",
+ "VariableDeclaration": ">=1",
+ "VariableDeclarationSemiColon": ">=1",
+ "VariableValue": -1,
+ "WhileStatement": ">=1",
+ "WhileStatementClosingBrace": ">=1",
+ "WhileStatementOpeningBrace": ">=1"
+ }
+ },
+ "whiteSpace": {
+ "value": " ",
+ "removeTrailing": 1,
+ "before": {
+ "ArgumentComma": 0,
+ "ArgumentList": 0,
+ "ArgumentListArrayExpression": 0,
+ "ArgumentListFunctionExpression": 1,
+ "ArgumentListObjectExpression": 0,
+ "ArrayExpressionClosing": 0,
+ "ArrayExpressionComma": 0,
+ "ArrayExpressionOpening": 1,
+ "AssignmentOperator": 1,
+ "BinaryExpression": 0,
+ "BinaryExpressionOperator": 1,
+ "BlockComment": 1,
+ "CallExpression": 1,
+ "CatchClosingBrace": 1,
+ "CatchKeyword": 1,
+ "CatchOpeningBrace": 1,
+ "CatchParameterList": 0,
+ "CommaOperator": 0,
+ "ConditionalExpressionAlternate": 1,
+ "ConditionalExpressionConsequent": 1,
+ "DoWhileStatementClosingBrace": 1,
+ "DoWhileStatementConditional": 1,
+ "DoWhileStatementOpeningBrace": 1,
+ "ElseIfStatementClosingBrace": 1,
+ "ElseIfStatementOpeningBrace": 1,
+ "ElseStatementClosingBrace": 1,
+ "ElseStatementOpeningBrace": 1,
+ "EmptyStatement": 0,
+ "ExpressionClosingParentheses": 0,
+ "FinallyClosingBrace": 1,
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": 1,
+ "ForInStatement": 1,
+ "ForInStatementClosingBrace": 1,
+ "ForInStatementExpressionClosing": 0,
+ "ForInStatementExpressionOpening": 1,
+ "ForInStatementOpeningBrace": 1,
+ "ForStatement": 1,
+ "ForStatementClosingBrace": 1,
+ "ForStatementExpressionClosing": 0,
+ "ForStatementExpressionOpening": 1,
+ "ForStatementOpeningBrace": 1,
+ "ForStatementSemicolon": 0,
+ "FunctionDeclarationClosingBrace": 1,
+ "FunctionDeclarationOpeningBrace": 1,
+ "FunctionExpressionClosingBrace": 1,
+ "FunctionExpressionOpeningBrace": 1,
+ "IfStatementClosingBrace": 1,
+ "IfStatementConditionalClosing": 0,
+ "IfStatementConditionalOpening": 1,
+ "IfStatementOpeningBrace": 1,
+ "LineComment": 1,
+ "LogicalExpressionOperator": 1,
+ "MemberExpressionClosing": 0,
+ "ObjectExpressionClosingBrace": 1,
+ "ParameterComma": 0,
+ "ParameterList": 0,
+ "Property": 1,
+ "PropertyName": 1,
+ "PropertyValue": 1,
+ "SwitchDiscriminantClosing": 0,
+ "SwitchDiscriminantOpening": 1,
+ "ThrowKeyword": 1,
+ "TryClosingBrace": 1,
+ "TryKeyword": -1,
+ "TryOpeningBrace": 1,
+ "UnaryExpressionOperator": 0,
+ "VariableName": 1,
+ "VariableValue": 1,
+ "WhileStatementClosingBrace": 1,
+ "WhileStatementConditionalClosing": 0,
+ "WhileStatementConditionalOpening": 1,
+ "WhileStatementOpeningBrace": 1
+ },
+ "after": {
+ "ArgumentComma": 1,
+ "ArgumentList": 0,
+ "ArgumentListArrayExpression": 1,
+ "ArgumentListFunctionExpression": 1,
+ "ArgumentListObjectExpression": 0,
+ "ArrayExpressionClosing": 0,
+ "ArrayExpressionComma": 1,
+ "ArrayExpressionOpening": 0,
+ "AssignmentOperator": 1,
+ "BinaryExpression": 0,
+ "BinaryExpressionOperator": 1,
+ "BlockComment": 1,
+ "CallExpression": 0,
+ "CatchClosingBrace": 1,
+ "CatchKeyword": 1,
+ "CatchOpeningBrace": 1,
+ "CatchParameterList": 0,
+ "CommaOperator": 1,
+ "ConditionalExpressionConsequent": 1,
+ "ConditionalExpressionTest": 1,
+ "DoWhileStatementBody": 1,
+ "DoWhileStatementClosingBrace": 1,
+ "DoWhileStatementOpeningBrace": 1,
+ "ElseIfStatementClosingBrace": 1,
+ "ElseIfStatementOpeningBrace": 1,
+ "ElseStatementClosingBrace": 1,
+ "ElseStatementOpeningBrace": 1,
+ "EmptyStatement": 0,
+ "ExpressionOpeningParentheses": 0,
+ "FinallyClosingBrace": 1,
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": 1,
+ "ForInStatement": 1,
+ "ForInStatementClosingBrace": 1,
+ "ForInStatementExpressionClosing": 1,
+ "ForInStatementExpressionOpening": 0,
+ "ForInStatementOpeningBrace": 1,
+ "ForStatement": 1,
+ "ForStatementClosingBrace": 1,
+ "ForStatementExpressionClosing": 1,
+ "ForStatementExpressionOpening": 0,
+ "ForStatementOpeningBrace": 1,
+ "ForStatementSemicolon": 1,
+ "FunctionDeclarationClosingBrace": 0,
+ "FunctionDeclarationOpeningBrace": 0,
+ "FunctionExpressionClosingBrace": 0,
+ "FunctionExpressionOpeningBrace": 0,
+ "FunctionName": 0,
+ "FunctionReservedWord": 0,
+ "IfStatementClosingBrace": 1,
+ "IfStatementConditionalClosing": 0,
+ "IfStatementConditionalOpening": 0,
+ "IfStatementOpeningBrace": 1,
+ "LogicalExpressionOperator": 1,
+ "MemberExpressionOpening": 0,
+ "ObjectExpressionClosingBrace": 0,
+ "ObjectExpressionOpeningBrace": 1,
+ "ParameterComma": 1,
+ "ParameterList": 0,
+ "PropertyName": 0,
+ "PropertyValue": 0,
+ "SwitchDiscriminantClosing": 1,
+ "SwitchDiscriminantOpening": 0,
+ "ThrowKeyword": 1,
+ "TryClosingBrace": 1,
+ "TryKeyword": -1,
+ "TryOpeningBrace": 1,
+ "UnaryExpressionOperator": 0,
+ "VariableName": 1,
+ "WhileStatementClosingBrace": 1,
+ "WhileStatementConditionalClosing": 1,
+ "WhileStatementConditionalOpening": 0,
+ "WhileStatementOpeningBrace": 1
+ }
+ }
+}
diff --git a/frontend/.eslintignore b/frontend/.eslintignore
new file mode 100644
index 000000000..d4b43f836
--- /dev/null
+++ b/frontend/.eslintignore
@@ -0,0 +1 @@
+**/JsLibraries/**
diff --git a/frontend/.eslintrc b/frontend/.eslintrc
new file mode 100644
index 000000000..31b1173ec
--- /dev/null
+++ b/frontend/.eslintrc
@@ -0,0 +1,288 @@
+{
+ "parser": "babel-eslint",
+
+ "env": {
+ "browser": true,
+ "commonjs": true,
+ "node": true,
+ "es6": true
+ },
+
+ "globals": {
+ "expect": false,
+ "chai": false,
+ "sinon": false
+ },
+
+ "parserOptions": {
+ "ecmaVersion": 6,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "modules": true,
+ "impliedStrict": true
+ }
+ },
+
+ "plugins": [
+ "filenames",
+ "react"
+ ],
+
+ "rules": {
+ "filenames/match-exported": ["error"],
+
+ # ECMAScript 6
+
+ "arrow-body-style": [0],
+ "arrow-parens": ["error", "always"],
+ "arrow-spacing": ["error", { "before": true, "after": true }],
+ "constructor-super": "error",
+ "generator-star-spacing": "off",
+ "no-class-assign": "error",
+ "no-confusing-arrow": "error",
+ "no-const-assign": "error",
+ "no-dupe-class-members": "error",
+ "no-duplicate-imports": "error",
+ "no-new-symbol": "error",
+ "no-this-before-super": "error",
+ "no-useless-escape": "error",
+ "no-useless-computed-key": "error",
+ "no-useless-constructor": "error",
+ "no-var": "warn",
+ "object-shorthand": ["error", "properties"],
+ "prefer-arrow-callback": "error",
+ "prefer-const": "warn",
+ "prefer-reflect": "off",
+ "prefer-rest-params": "off",
+ "prefer-spread": "warn",
+ "prefer-template": "error",
+ "require-yield": "off",
+ "template-curly-spacing": ["error", "never"],
+ "yield-star-spacing": "off",
+
+ # Possible Errors
+
+ "comma-dangle": "error",
+ "no-cond-assign": "error",
+ "no-console": "off",
+ "no-constant-condition": "warn",
+ "no-control-regex": "error",
+ "no-debugger": "off",
+ "no-dupe-args": "error",
+ "no-dupe-keys": "error",
+ "no-duplicate-case": "error",
+ "no-empty": "warn",
+ "no-empty-character-class": "error",
+ "no-ex-assign": "error",
+ "no-extra-boolean-cast": "error",
+ "no-extra-parens": ["error", "functions"],
+ "no-extra-semi": "error",
+ "no-func-assign": "error",
+ "no-inner-declarations": "error",
+ "no-invalid-regexp": "error",
+ "no-irregular-whitespace": "error",
+ "no-negated-in-lhs": "error",
+ "no-obj-calls": "error",
+ "no-regex-spaces": "error",
+ "no-sparse-arrays": "error",
+ "no-unexpected-multiline": "error",
+ "no-unreachable": "warn",
+ "no-unsafe-finally": "error",
+ "use-isnan": "error",
+ "valid-jsdoc": "off",
+ "valid-typeof": "error",
+
+ # Best Practices
+
+ "accessor-pairs": "off",
+ "array-callback-return": "warn",
+ "block-scoped-var": "warn",
+ "consistent-return": "off",
+ "curly": "error",
+ "default-case": "error",
+ "dot-location": ["error", "property"],
+ "dot-notation": "error",
+ "eqeqeq": ["error", "smart"],
+ "guard-for-in": "error",
+ "no-alert": "warn",
+ "no-caller": "error",
+ "no-case-declarations": "error",
+ "no-div-regex": "error",
+ "no-else-return": "error",
+ "no-empty-function": ["error", {"allow": ["arrowFunctions"]}],
+ "no-empty-pattern": "error",
+ "no-eval": "error",
+ "no-extend-native": "error",
+ "no-extra-bind": "error",
+ "no-fallthrough": "error",
+ "no-floating-decimal": "error",
+ "no-implicit-coercion": ["error", {
+ "boolean": false,
+ "number": true,
+ "string": true,
+ "allow": [/* "!!", "~", "*", "+" */]
+ }],
+ "no-implicit-globals": "error",
+ "no-implied-eval": "error",
+ "no-invalid-this": "off",
+ "no-iterator": "error",
+ "no-labels": "error",
+ "no-lone-blocks": "error",
+ "no-loop-func": "error",
+ "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }],
+ "no-multi-spaces": "error",
+ "no-multi-str": "error",
+ "no-native-reassign": ["error", {"exceptions": ["console"]}],
+ "no-new": "off",
+ "no-new-func": "error",
+ "no-new-wrappers": "error",
+ "no-octal": "error",
+ "no-octal-escape": "error",
+ "no-param-reassign": "off",
+ "no-process-env": "off",
+ "no-proto": "error",
+ "no-redeclare": "error",
+ "no-return-assign": "warn",
+ "no-script-url": "error",
+ "no-self-assign": "error",
+ "no-self-compare": "error",
+ "no-sequences": "error",
+ "no-throw-literal": "error",
+ "no-unmodified-loop-condition": "error",
+ "no-unused-expressions": "error",
+ "no-unused-labels": "error",
+ "no-useless-call": "error",
+ "no-useless-concat": "error",
+ "no-void": "error",
+ "no-warning-comments": "off",
+ "no-with": "error",
+ "radix": ["error", "as-needed"],
+ "vars-on-top": "off",
+ "wrap-iife": ["error", "inside"],
+ "yoda": "error",
+
+ # Strict Mode
+
+ "strict": ["error", "never"],
+
+ # Variables
+
+ "init-declarations": ["error", "always"],
+ "no-catch-shadow": "error",
+ "no-delete-var": "error",
+ "no-label-var": "error",
+ "no-restricted-globals": "off",
+ "no-shadow": "error",
+ "no-shadow-restricted-names": "error",
+ "no-undef": "error",
+ "no-undef-init": "off",
+ "no-undefined": "off",
+ "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }],
+ "no-use-before-define": "error",
+
+ # Node.js and CommonJS
+
+ "callback-return": "warn",
+ "global-require": "error",
+ "handle-callback-err": "warn",
+ "no-mixed-requires": "error",
+ "no-new-require": "error",
+ "no-path-concat": "error",
+ "no-process-exit": "error",
+
+ # Stylistic Issues
+
+ "array-bracket-spacing": ["error", "never"],
+ "block-spacing": ["error", "always"],
+ "brace-style": ["error", "1tbs", { "allowSingleLine": false }],
+ "camelcase": "off",
+ "comma-spacing": ["error", {"before": false, "after": true}],
+ "comma-style": ["error", "last"],
+ "computed-property-spacing": ["error", "never"],
+ "consistent-this": ["error", "self"],
+ "eol-last": "error",
+ "func-names": "off",
+ "func-style": ["error", "declaration"],
+ "indent": ["error", 2, {"SwitchCase": 1}],
+ "key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
+ "keyword-spacing": ["error", { "before": true, "after": true}],
+ "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }],
+ "max-depth": ["error", {"maximum": 5}],
+ "max-nested-callbacks": ["error", 4],
+ "max-params": ["error", 6],
+ "max-statements": "off",
+ "max-statements-per-line": ["error", { "max": 1 }],
+ "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}],
+ "new-parens": "error",
+ "newline-after-var": "off",
+ "newline-before-return": "off",
+ "newline-per-chained-call": "off",
+ "no-array-constructor": "error",
+ "no-bitwise": "error",
+ "no-continue": "error",
+ "no-inline-comments": "off",
+ "no-lonely-if": "warn",
+ "no-mixed-spaces-and-tabs": "error",
+ "no-multiple-empty-lines": ["error", { "max": 1 }],
+ "no-negated-condition": "warn",
+ "no-nested-ternary": "error",
+ "no-new-object": "error",
+ "no-plusplus": "off",
+ "no-restricted-syntax": "off",
+ "no-spaced-func": "error",
+ "no-ternary": "off",
+ "no-trailing-spaces": "error",
+ "no-underscore-dangle": ["error", { "allowAfterThis": true }],
+ "no-unneeded-ternary": "error",
+ "no-whitespace-before-property": "error",
+ "object-curly-spacing": ["error", "always"],
+ "one-var": ["error", "never"],
+ "one-var-declaration-per-line": ["error", "always"],
+ "operator-assignment": ["off", "never"],
+ "operator-linebreak": ["error", "after"],
+ "quote-props": ["error", "as-needed"],
+ "quotes": ["error", "single"],
+ "require-jsdoc": "off",
+ "semi": "error",
+ "semi-spacing": ["error", { "before": false, "after": true }],
+ "sort-vars": "off",
+ "space-before-blocks": ["error", "always"],
+ "space-before-function-paren": ["error", "never"],
+ "space-in-parens": "off",
+ "space-infix-ops": "off",
+ "space-unary-ops": "off",
+ "spaced-comment": "error",
+ "wrap-regex": "error",
+
+ # React
+
+ "react/jsx-boolean-value": [2, "always"],
+ "react/jsx-uses-vars": 2,
+ "react/jsx-closing-bracket-location": 2,
+ "react/jsx-tag-spacing": ["error"],
+ "react/jsx-curly-spacing": [2, "never"],
+ "react/jsx-equals-spacing": [2, "never"],
+ "react/jsx-indent-props": [2, 2],
+ "react/jsx-indent": [2, 2],
+ "react/jsx-key": 2,
+ "react/jsx-no-bind": [2, { "allowArrowFunctions": true }],
+ "react/jsx-no-duplicate-props": [2, { "ignoreCase": true }],
+ "react/jsx-max-props-per-line": [2, { "maximum": 2 }],
+ "react/jsx-handler-names": [2, { "eventHandlerPrefix": "(on|dispatch)", "eventHandlerPropPrefix": "on" }],
+ "react/jsx-no-undef": 2,
+ "react/jsx-pascal-case": 2,
+ "react/jsx-uses-react": 2,
+ // Explicitly disabled in case we want to enable them again
+ "react/no-did-mount-set-state": 0,
+ "react/no-did-update-set-state": 0,
+ "react/no-direct-mutation-state": 2,
+ "react/no-multi-comp": [2, { "ignoreStateless": true }],
+ "react/no-unknown-property": 2,
+ "react/prefer-es6-class": 2,
+ "react/prop-types": 2,
+ "react/react-in-jsx-scope": 2,
+ "react/self-closing-comp": 2,
+ "react/sort-comp": 2,
+ "react/jsx-wrap-multilines": 2
+ }
+}
diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc
new file mode 100644
index 000000000..50aa6aa29
--- /dev/null
+++ b/frontend/.jsbeautifyrc
@@ -0,0 +1,12 @@
+{
+ "js": {
+ "indent_size": 2,
+ "indent_char": " ",
+ "indent_level": 2,
+ "indent_with_tabs": false,
+ "preserve_newlines": true,
+ "brace_style": "collapse",
+ "max_preserve_newlines": 2,
+ "jslint_happy": true
+ }
+}
\ No newline at end of file
diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc
new file mode 100644
index 000000000..5587e5d4d
--- /dev/null
+++ b/frontend/.stylelintrc
@@ -0,0 +1,396 @@
+{
+"plugins": [
+ "stylelint-order"
+],
+"ignoreFiles": [
+ "frontend/src/Styles/scaffolding.css",
+ "**/*.js"
+],
+"rules": {
+ "at-rule-empty-line-before": [
+ "always",
+ {
+ "except": [
+ "inside-block"
+ ]
+ }
+ ],
+ "at-rule-name-case": "lower",
+ "at-rule-name-newline-after": "always-multi-line",
+ "at-rule-name-space-after": "always",
+ "at-rule-no-unknown": [
+ true,
+ {
+ "ignoreAtRules": [
+ "/^add\\-mixin$/",
+ "/^define\\-mixin$/"
+ ]
+ }
+ ],
+ "at-rule-no-vendor-prefix": true,
+ "at-rule-semicolon-newline-after": "always",
+ "at-rule-semicolon-space-before": "never",
+ "block-closing-brace-empty-line-before": "never",
+ "block-closing-brace-newline-after": "always",
+ "block-closing-brace-newline-before": "always",
+ "block-closing-brace-space-after": "always-single-line",
+ "block-closing-brace-space-before": "always-single-line",
+ "block-no-empty": true,
+ "block-opening-brace-newline-after": "always",
+ "block-opening-brace-newline-before": "never-single-line",
+ "block-opening-brace-space-after": "always-single-line",
+ "block-opening-brace-space-before": "always",
+ "color-hex-case": "lower",
+ "color-hex-length": "short",
+ "color-named": "never",
+ "color-no-invalid-hex": true,
+ "comment-whitespace-inside": "always",
+ "declaration-bang-space-after": "never",
+ "declaration-bang-space-before": "always",
+ "declaration-block-no-duplicate-properties": [
+ true,
+ {
+ "ignoreProperties": [
+ "composes"
+ ]
+ }
+ ],
+ "declaration-block-no-redundant-longhand-properties": true,
+ "declaration-block-no-shorthand-property-overrides": true,
+ "declaration-block-semicolon-newline-after": "always",
+ "declaration-block-semicolon-newline-before": "never-multi-line",
+ "declaration-block-semicolon-space-before": "never",
+ "declaration-block-single-line-max-declarations": 1,
+ "declaration-block-trailing-semicolon": "always",
+ "declaration-colon-space-after": "always",
+ "declaration-colon-space-before": "never",
+ "font-family-name-quotes": "always-unless-keyword",
+ "function-calc-no-unspaced-operator": true,
+ "function-comma-newline-after": "never-multi-line",
+ "function-comma-newline-before": "never-multi-line",
+ "function-comma-space-after": "always",
+ "function-comma-space-before": "never",
+ "function-linear-gradient-no-nonstandard-direction": true,
+ "function-name-case": "lower",
+ "function-parentheses-newline-inside": "never-multi-line",
+ "function-parentheses-space-inside": "never",
+ "function-url-quotes": "always",
+ "function-url-scheme-blacklist": [
+ "data"
+ ],
+ "function-whitespace-after": "always",
+ "indentation": 2,
+ "keyframe-declaration-no-important": true,
+ "length-zero-no-unit": true,
+ "max-empty-lines": 1,
+ "max-line-length": [
+ 100,
+ {
+ "ignore": [
+ "non-comments"
+ ]
+ }
+ ],
+ "max-nesting-depth": 2,
+ "media-feature-colon-space-after": "always",
+ "media-feature-colon-space-before": "never",
+ "media-feature-name-case": "lower",
+ "media-feature-name-no-vendor-prefix": true,
+ "media-feature-range-operator-space-after": "always",
+ "media-feature-range-operator-space-before": "always",
+ "no-empty-source": true,
+ "no-eol-whitespace": true,
+ "no-extra-semicolons": true,
+ "no-invalid-double-slash-comments": true,
+ "no-missing-end-of-source-newline": true,
+ "number-leading-zero": "always",
+ "number-no-trailing-zeros": true,
+ "order/order": [
+ "custom-properties",
+ "dollar-variables",
+ {
+ "hasBlock": false,
+ "name": "add-mixin",
+ "type": "at-rule"
+ },
+ "declarations",
+ "rules",
+ "at-rules"
+ ],
+ "order/properties-order": [
+ {
+ "emptyLineBefore": "always",
+ "properties": [
+ "composes"
+ ]
+ },
+ {
+ "emptyLineBefore": "always",
+ "properties": [
+ "position",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "z-index",
+ "display",
+ "visibility",
+ "align-content",
+ "align-items",
+ "align-self",
+ "justify-content",
+ "flex",
+ "flex-direction",
+ "flex-order",
+ "flex-pack",
+ "flex-align",
+ "flex-grow",
+ "flex-shrink",
+ "flex-basis",
+ "flex-wrap",
+ "flex-flow",
+ "float",
+ "clear",
+ "overflow",
+ "overflow-x",
+ "overflow-y",
+ "-webkit-overflow-scrolling",
+ "clip",
+ "box-sizing",
+ "margin",
+ "margin-top",
+ "margin-right",
+ "margin-bottom",
+ "margin-left",
+ "padding",
+ "padding-top",
+ "padding-right",
+ "padding-bottom",
+ "padding-left",
+ "min-width",
+ "min-height",
+ "max-width",
+ "max-height",
+ "width",
+ "height",
+ "outline",
+ "outline-width",
+ "outline-style",
+ "outline-color",
+ "outline-offset",
+ "border",
+ "border-spacing",
+ "border-collapse",
+ "border-width",
+ "border-style",
+ "border-color",
+ "border-top",
+ "border-top-width",
+ "border-top-style",
+ "border-top-color",
+ "border-right",
+ "border-right-width",
+ "border-right-style",
+ "border-right-color",
+ "border-bottom",
+ "border-bottom-width",
+ "border-bottom-style",
+ "border-bottom-color",
+ "border-left",
+ "border-left-width",
+ "border-left-style",
+ "border-left-color",
+ "border-radius",
+ "border-top-left-radius",
+ "border-top-right-radius",
+ "border-bottom-right-radius",
+ "border-bottom-left-radius",
+ "border-image",
+ "border-image-source",
+ "border-image-slice",
+ "border-image-width",
+ "border-image-outset",
+ "border-image-repeat",
+ "border-top-image",
+ "border-right-image",
+ "border-bottom-image",
+ "border-left-image",
+ "border-corner-image",
+ "border-top-left-image",
+ "border-top-right-image",
+ "border-bottom-right-image",
+ "border-bottom-left-image",
+ "background",
+ "background-color",
+ "background-image",
+ "background-attachment",
+ "background-position",
+ "background-position-x",
+ "background-position-y",
+ "background-clip",
+ "background-origin",
+ "background-size",
+ "background-repeat",
+ "box-decoration-break",
+ "box-shadow",
+ "color",
+ "table-layout",
+ "caption-side",
+ "empty-cells",
+ "list-style",
+ "list-style-position",
+ "list-style-type",
+ "list-style-image",
+ "quotes",
+ "content",
+ "counter-increment",
+ "counter-reset",
+ "-ms-writing-mode",
+ "vertical-align",
+ "text-align",
+ "text-align-last",
+ "text-decoration",
+ "text-emphasis",
+ "text-emphasis-position",
+ "text-emphasis-style",
+ "text-emphasis-color",
+ "text-indent",
+ "text-justify",
+ "text-outline",
+ "text-transform",
+ "text-wrap",
+ "text-overflow",
+ "text-overflow-ellipsis",
+ "text-overflow-mode",
+ "text-shadow",
+ "white-space",
+ "word-spacing",
+ "word-wrap",
+ "word-break",
+ "tab-size",
+ "hyphens",
+ "letter-spacing",
+ "font",
+ "font-weight",
+ "font-style",
+ "font-variant",
+ "font-size-adjust",
+ "font-stretch",
+ "font-size",
+ "font-family",
+ "font-smoothing",
+ "-moz-osx-font-smoothing",
+ "-webkit-font-smoothing",
+ "src",
+ "line-height",
+ "opacity",
+ "filter",
+ "resize",
+ "cursor",
+ "appearance",
+ "nav-index",
+ "nav-up",
+ "nav-right",
+ "nav-down",
+ "nav-left",
+ "transition",
+ "transition-delay",
+ "transition-timing-function",
+ "transition-duration",
+ "transition-property",
+ "transform",
+ "transform-origin",
+ "transform-style",
+ "backface-visibility",
+ "animation",
+ "animation-name",
+ "animation-duration",
+ "animation-play-state",
+ "animation-timing-function",
+ "animation-delay",
+ "animation-iteration-count",
+ "animation-direction",
+ "animation-fill-mode",
+ "pointer-events",
+ "user-select",
+ "touch-action",
+ "-webkit-tap-highlight-color",
+ "unicode-bidi",
+ "direction",
+ "columns",
+ "column-span",
+ "column-width",
+ "column-count",
+ "column-fill",
+ "column-gap",
+ "column-rule",
+ "column-rule-width",
+ "column-rule-style",
+ "column-rule-color",
+ "break-before",
+ "break-inside",
+ "break-after",
+ "page-break-before",
+ "page-break-inside",
+ "page-break-after",
+ "orphans",
+ "widows",
+ "zoom",
+ "max-zoom",
+ "min-zoom",
+ "user-zoom",
+ "orientation"
+ ]
+ }
+ ],
+ "property-case": "lower",
+ "property-no-vendor-prefix": true,
+ "rule-empty-line-before": [
+ "always",
+ {
+ "except": [
+ "first-nested"
+ ],
+ "ignore": [
+ "after-comment"
+ ]
+ }
+ ],
+ "selector-attribute-brackets-space-inside": "never",
+ "selector-attribute-operator-space-after": "never",
+ "selector-attribute-operator-space-before": "never",
+ "selector-attribute-quotes": "never",
+ "selector-class-pattern": "^[A-Za-z0-9]+$",
+ "selector-combinator-space-after": "always",
+ "selector-combinator-space-before": "always",
+ "selector-descendant-combinator-no-non-space": true,
+ "selector-list-comma-newline-after": "always",
+ "selector-list-comma-newline-before": "never-multi-line",
+ "selector-list-comma-space-before": "never",
+ "selector-max-attribute": 0,
+ "selector-max-class": 3,
+ "selector-max-compound-selectors": 3,
+ "selector-max-empty-lines": 0,
+ "selector-max-id": 0,
+ "selector-max-universal": 0,
+ "selector-pseudo-class-case": "lower",
+ "selector-pseudo-class-parentheses-space-inside": "never",
+ "selector-pseudo-element-case": "lower",
+ "selector-pseudo-element-colon-notation": "double",
+ "selector-pseudo-element-no-unknown": true,
+ "selector-type-case": "lower",
+ "selector-type-no-unknown": true,
+ "shorthand-property-no-redundant-values": true,
+ "string-no-newline": true,
+ "string-quotes": "single",
+ "time-min-milliseconds": 100,
+ "unit-case": "lower",
+ "unit-no-unknown": true,
+ "value-list-comma-newline-after": "never-multi-line",
+ "value-list-comma-newline-before": "never-multi-line",
+ "value-list-comma-space-after": "always",
+ "value-list-comma-space-before": "never",
+ "value-list-max-empty-lines": 0,
+ "value-no-vendor-prefix": true
+ }
+}
diff --git a/frontend/.tern-project b/frontend/.tern-project
new file mode 100644
index 000000000..aa9d76407
--- /dev/null
+++ b/frontend/.tern-project
@@ -0,0 +1,7 @@
+{
+ "ecmaVersion": 6,
+ "libs": [
+ "browser",
+ "jquery"
+ ]
+}
diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js
new file mode 100644
index 000000000..cfeb5d138
--- /dev/null
+++ b/frontend/gulp/build.js
@@ -0,0 +1,15 @@
+const gulp = require('gulp');
+const runSequence = require('run-sequence');
+
+require('./clean');
+require('./copy');
+
+gulp.task('build', () => {
+ return runSequence('clean', [
+ 'webpack',
+ 'copyHtml',
+ 'copyFonts',
+ 'copyImages',
+ 'copyJs'
+ ]);
+});
diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js
new file mode 100644
index 000000000..ac2e4026f
--- /dev/null
+++ b/frontend/gulp/clean.js
@@ -0,0 +1,8 @@
+const gulp = require('gulp');
+const del = require('del');
+
+const paths = require('./helpers/paths');
+
+gulp.task('clean', () => {
+ return del([paths.dest.root]);
+});
diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js
new file mode 100644
index 000000000..5b48eb755
--- /dev/null
+++ b/frontend/gulp/copy.js
@@ -0,0 +1,45 @@
+var path = require('path');
+var gulp = require('gulp');
+var print = require('gulp-print').default;
+var cache = require('gulp-cached');
+var livereload = require('gulp-livereload');
+var paths = require('./helpers/paths.js');
+
+gulp.task('copyJs', () => {
+ return gulp.src(
+ [
+ path.join(paths.src.root, 'polyfills.js')
+ ])
+ .pipe(cache('copyJs'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyHtml', () => {
+ return gulp.src(paths.src.html)
+ .pipe(cache('copyHtml'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyFonts', () => {
+ return gulp.src(
+ path.join(paths.src.fonts, '**', '*.*')
+ )
+ .pipe(cache('copyFonts'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.fonts))
+ .pipe(livereload());
+});
+
+gulp.task('copyImages', () => {
+ return gulp.src(
+ path.join(paths.src.images, '**', '*.*')
+ )
+ .pipe(cache('copyImages'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.images))
+ .pipe(livereload());
+});
diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js
new file mode 100644
index 000000000..821935a31
--- /dev/null
+++ b/frontend/gulp/gulpFile.js
@@ -0,0 +1,10 @@
+require('./build.js');
+require('./clean.js');
+require('./copy.js');
+require('./handlebars.js');
+require('./imageMin.js');
+require('./less.js');
+require('./start.js');
+require('./stripBom.js');
+require('./watch.js');
+require('./webpack.js');
diff --git a/frontend/gulp/handlebars.js b/frontend/gulp/handlebars.js
new file mode 100644
index 000000000..679a0b7f6
--- /dev/null
+++ b/frontend/gulp/handlebars.js
@@ -0,0 +1,70 @@
+var gulp = require('gulp');
+var handlebars = require('gulp-handlebars');
+var declare = require('gulp-declare');
+var concat = require('gulp-concat');
+var wrap = require('gulp-wrap');
+var livereload = require('gulp-livereload');
+var path = require('path');
+var streamqueue = require('streamqueue');
+var stripbom = require('gulp-stripbom');
+var compliler = require('handlebars');
+
+var errorHandler = require('./helpers/errorHandler');
+var paths = require('./helpers/paths.js');
+
+console.log('Handlebars (gulp) Version: ', compliler.VERSION);
+console.log('Handlebars (gulp) Compiler: ', compliler.COMPILER_REVISION);
+
+gulp.task('handlebars', () => {
+ var coreStream = gulp.src([
+ paths.src.templates,
+ '!*/**/*Partial.*'
+ ])
+ .pipe(stripbom({
+ showLog: false
+ }))
+ .pipe(handlebars({
+ handlebars: compliler
+ }))
+ .on('error', errorHandler)
+ .pipe(declare({
+ namespace: 'T',
+ noRedeclare: true,
+ processName: (filePath) => {
+ filePath = path.relative(paths.src.root, filePath);
+
+ return filePath.replace(/\\/g, '/')
+ .toLocaleLowerCase()
+ .replace('template', '')
+ .replace('.js', '');
+ }
+ }));
+
+ var partialStream = gulp.src([paths.src.partials])
+ .pipe(stripbom({
+ showLog: false
+ }))
+ .pipe(handlebars({
+ handlebars: compliler
+ }))
+ .on('error', errorHandler)
+ .pipe(wrap('Handlebars.template(<%= contents %>)'))
+ .pipe(wrap('Handlebars.registerPartial(<%= processPartialName(file.relative) %>, <%= contents %>)', {}, {
+ imports: {
+ processPartialName: function(fileName) {
+ return JSON.stringify(
+ path.basename(fileName, '.js')
+ );
+ }
+ }
+ }));
+
+ return streamqueue({
+ objectMode: true
+ },
+ partialStream,
+ coreStream
+ ).pipe(concat('templates.js'))
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js
new file mode 100644
index 000000000..f3e1c113b
--- /dev/null
+++ b/frontend/gulp/helpers/errorHandler.js
@@ -0,0 +1,6 @@
+const gulpUtil = require('gulp-util');
+
+module.exports = function errorHandler(error) {
+ gulpUtil.log(gulpUtil.colors.red(`Error (${error.plugin}): ${error.message}`));
+ this.emit('end');
+};
diff --git a/frontend/gulp/helpers/html-annotate-loader.js b/frontend/gulp/helpers/html-annotate-loader.js
new file mode 100644
index 000000000..6c7ce10b8
--- /dev/null
+++ b/frontend/gulp/helpers/html-annotate-loader.js
@@ -0,0 +1,15 @@
+const path = require('path');
+const rootPath = path.resolve(__dirname + '/../../src/');
+module.exports = function(source) {
+ if (this.cacheable) {
+ this.cacheable();
+ }
+
+ const resourcePath = this.resourcePath.replace(rootPath, '');
+ const wrappedSource =`
+
+ ${source}
+ `;
+
+ return wrappedSource;
+};
diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js
new file mode 100644
index 000000000..88498d6f7
--- /dev/null
+++ b/frontend/gulp/helpers/paths.js
@@ -0,0 +1,26 @@
+const root = './frontend/src/';
+
+const paths = {
+ src: {
+ root,
+ templates: root + '**/*.hbs',
+ html: root + '*.html',
+ partials: root + '**/*Partial.hbs',
+ scripts: root + '**/*.js',
+ less: [root + '**/*.less'],
+ content: root + 'Content/',
+ fonts: root + 'Content/Fonts/',
+ images: root + 'Content/Images/',
+ exclude: {
+ libs: `!${root}JsLibraries/**`
+ }
+ },
+ dest: {
+ root: './_output/UI.Phantom/',
+ content: './_output/UI.Phantom/Content/',
+ fonts: './_output/UI.Phantom/Content/Fonts/',
+ images: './_output/UI.Phantom/Content/Images/'
+ }
+};
+
+module.exports = paths;
diff --git a/frontend/gulp/helpers/phantom.js b/frontend/gulp/helpers/phantom.js
new file mode 100644
index 000000000..1d696853a
--- /dev/null
+++ b/frontend/gulp/helpers/phantom.js
@@ -0,0 +1,10 @@
+var phantom = false;
+process.argv.forEach((val) => {
+ if (val === '--phantom') {
+ phantom = true;
+ }
+});
+
+console.log('Phantom:', phantom);
+
+module.exports = phantom;
diff --git a/frontend/gulp/imageMin.js b/frontend/gulp/imageMin.js
new file mode 100644
index 000000000..8988c7ad4
--- /dev/null
+++ b/frontend/gulp/imageMin.js
@@ -0,0 +1,15 @@
+var gulp = require('gulp');
+var print = require('gulp-print').default;
+var paths = require('./helpers/paths.js');
+
+gulp.task('imageMin', () => {
+ var imagemin = require('gulp-imagemin');
+ return gulp.src(paths.src.images)
+ .pipe(imagemin({
+ progressive: false,
+ optimizationLevel: 4,
+ svgoPlugins: [{ removeViewBox: false }]
+ }))
+ .pipe(print())
+ .pipe(gulp.dest(paths.src.content + 'Images/'));
+});
diff --git a/frontend/gulp/less.js b/frontend/gulp/less.js
new file mode 100644
index 000000000..797cb80cf
--- /dev/null
+++ b/frontend/gulp/less.js
@@ -0,0 +1,46 @@
+const gulp = require('gulp');
+
+const less = require('gulp-less');
+const postcss = require('gulp-postcss');
+const sourcemaps = require('gulp-sourcemaps');
+const autoprefixer = require('autoprefixer');
+const livereload = require('gulp-livereload');
+const path = require('path');
+
+const print = require('gulp-print');
+const paths = require('./helpers/paths');
+const errorHandler = require('./helpers/errorHandler');
+
+gulp.task('less', () => {
+ const src = [
+ path.join(paths.src.content, 'Bootstrap', 'bootstrap.less'),
+ path.join(paths.src.content, 'Vendor', 'vendor.less'),
+ path.join(paths.src.content, 'sonarr.less')
+ ];
+
+ return gulp.src(src)
+ .pipe(print())
+ .pipe(sourcemaps.init())
+ .pipe(less({
+ paths: [paths.src.root],
+ dumpLineNumbers: 'false',
+ compress: true,
+ yuicompress: true,
+ ieCompat: true,
+ strictImports: true
+ }))
+ .on('error', errorHandler)
+ .pipe(postcss([autoprefixer({
+ browsers: ['last 2 versions']
+ })]))
+ .on('error', errorHandler)
+
+ // not providing a path will cause the source map
+ // to be embeded. which makes livereload much happier
+ // since it doesn't reload the whole page to load the map.
+ // this should be switched to sourcemaps.write('./') for production builds
+ .pipe(sourcemaps.write())
+ .pipe(gulp.dest(paths.dest.content))
+ .on('error', errorHandler)
+ .pipe(livereload());
+});
diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js
new file mode 100644
index 000000000..eda9f0dba
--- /dev/null
+++ b/frontend/gulp/start.js
@@ -0,0 +1,104 @@
+// will download and run sonarr (server) in a non-windows enviroment
+// you can use this if you don't care about the server code and just want to work
+// with the web code.
+
+var http = require('http');
+var gulp = require('gulp');
+var fs = require('fs');
+var targz = require('tar.gz');
+var del = require('del');
+var spawn = require('child_process').spawn;
+
+function download(url, dest, cb) {
+ console.log('Downloading ' + url + ' to ' + dest);
+ var file = fs.createWriteStream(dest);
+ http.get(url, function(response) {
+ response.pipe(file);
+ file.on('finish', function() {
+ console.log('Download completed');
+ file.close(cb);
+ });
+ });
+}
+
+function getLatest(cb) {
+ var branch = 'develop';
+ process.argv.forEach(function(val) {
+ var branchMatch = /branch=([\S]*)/.exec(val);
+ if (branchMatch && branchMatch.length > 1) {
+ branch = branchMatch[1];
+ }
+ });
+
+ var url = 'http://services.sonarr.tv/v1/update/' + branch + '?os=osx';
+
+ console.log('Checking for latest version:', url);
+
+ http.get(url, function(res) {
+ var data = '';
+
+ res.on('data', function(chunk) {
+ data += chunk;
+ });
+
+ res.on('end', function() {
+ var updatePackage = JSON.parse(data).updatePackage;
+ console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate);
+ cb(updatePackage);
+ });
+ }).on('error', function(e) {
+ console.log('problem with request: ' + e.message);
+ });
+}
+
+function extract(source, dest, cb) {
+ console.log('extracting download page to ' + dest);
+ new targz().extract(source, dest, function(err) {
+ if (err) {
+ console.log(err);
+ }
+ console.log('Update package extracted.');
+ cb();
+ });
+}
+
+gulp.task('getSonarr', function() {
+ try {
+ fs.mkdirSync('./_start/');
+ } catch (e) {
+ if (e.code !== 'EEXIST') {
+ throw e;
+ }
+ }
+
+ getLatest(function(updatePackage) {
+ var packagePath = './_start/' + updatePackage.filename;
+ var dirName = './_start/' + updatePackage.version;
+ download(updatePackage.url, packagePath, function() {
+ extract(packagePath, dirName, function() {
+ // clean old binaries
+ console.log('Cleaning old binaries');
+ del.sync(['./_output/*', '!./_output/UI/']);
+ console.log('copying binaries to target');
+ gulp.src(dirName + '/NzbDrone/*.*')
+ .pipe(gulp.dest('./_output/'));
+ });
+ });
+ });
+});
+
+gulp.task('startSonarr', function() {
+ var ls = spawn('mono', ['--debug', './_output/NzbDrone.exe']);
+
+ ls.stdout.on('data', function(data) {
+ process.stdout.write(data);
+ });
+
+ ls.stderr.on('data', function(data) {
+ process.stdout.write(data);
+ });
+
+ ls.on('close', function(code) {
+ console.log('child process exited with code ' + code);
+ });
+});
diff --git a/frontend/gulp/stripBom.js b/frontend/gulp/stripBom.js
new file mode 100644
index 000000000..86a20bbe9
--- /dev/null
+++ b/frontend/gulp/stripBom.js
@@ -0,0 +1,21 @@
+const gulp = require('gulp');
+const paths = require('./helpers/paths.js');
+const stripbom = require('gulp-stripbom');
+
+function stripBom(dest) {
+ gulp.src([paths.src.scripts, paths.src.exclude.libs])
+ .pipe(stripbom({ showLog: false }))
+ .pipe(gulp.dest(dest));
+
+ gulp.src(paths.src.less)
+ .pipe(stripbom({ showLog: false }))
+ .pipe(gulp.dest(dest));
+
+ gulp.src(paths.src.templates)
+ .pipe(stripbom({ showLog: false }))
+ .pipe(gulp.dest(dest));
+}
+
+gulp.task('stripBom', () => {
+ stripBom(paths.src.root);
+});
diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js
new file mode 100644
index 000000000..ba1a47b66
--- /dev/null
+++ b/frontend/gulp/watch.js
@@ -0,0 +1,27 @@
+const gulp = require('gulp');
+const livereload = require('gulp-livereload');
+const watch = require('gulp-watch');
+const paths = require('./helpers/paths.js');
+
+require('./copy.js');
+require('./webpack.js');
+
+function watchTask(glob, task) {
+ const options = {
+ name: `watch: ${task}`,
+ verbose: true
+ };
+ return watch(glob, options, () => {
+ gulp.start(task);
+ });
+}
+
+gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => {
+ livereload.listen({ start: true });
+
+ gulp.start('webpackWatch');
+
+ watchTask(paths.src.html, 'copyHtml');
+ watchTask(`${paths.src.fonts}**/*.*`, 'copyFonts');
+ watchTask(`${paths.src.images}**/*.*`, 'copyImages');
+});
diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js
index 2593b0de4..50aefcc1a 100644
--- a/frontend/gulp/webpack.js
+++ b/frontend/gulp/webpack.js
@@ -1,15 +1,11 @@
-const _ = require('lodash');
const gulp = require('gulp');
-const simpleVars = require('postcss-simple-vars');
-const nested = require('postcss-nested');
-const autoprefixer = require('autoprefixer');
const webpackStream = require('webpack-stream');
const livereload = require('gulp-livereload');
const path = require('path');
const webpack = require('webpack');
const errorHandler = require('./helpers/errorHandler');
-const reload = require('require-nocache')(module);
const ExtractTextPlugin = require('extract-text-webpack-plugin');
+const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const uiFolder = 'UI';
const root = path.join(__dirname, '..', 'src');
@@ -18,66 +14,94 @@ const isProduction = process.argv.indexOf('--production') > -1;
console.log('ROOT:', root);
console.log('isProduction:', isProduction);
-const cssVariables = [
+const cssVarsFiles = [
'../src/Styles/Variables/colors',
'../src/Styles/Variables/dimensions',
'../src/Styles/Variables/fonts',
'../src/Styles/Variables/animations'
].map(require.resolve);
+const extractCSSPlugin = new ExtractTextPlugin({
+ filename: path.join('_output', uiFolder, 'Content', 'styles.css'),
+ allChunks: true,
+ disable: false,
+ ignoreOrder: true
+});
+
+const plugins = [
+ extractCSSPlugin,
+
+ new webpack.optimize.CommonsChunkPlugin({
+ name: 'vendor'
+ }),
+
+ new webpack.DefinePlugin({
+ __DEV__: !isProduction,
+ 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development')
+ })
+];
+
+if (isProduction) {
+ plugins.push(new UglifyJSPlugin({
+ sourceMap: true,
+ uglifyOptions: {
+ mangle: false,
+ output: {
+ comments: false,
+ beautify: true
+ }
+ }
+ }));
+}
+
const config = {
devtool: '#source-map',
+
stats: {
children: false
},
+
watchOptions: {
ignored: /node_modules/
},
+
entry: {
preload: 'preload.js',
vendor: 'vendor.js',
index: 'index.js'
},
+
resolve: {
- root: [
+ modules: [
root,
path.join(root, 'Shims'),
- path.join(root, 'JsLibraries')
- ]
+ 'node_modules'
+ ],
+ alias: {
+ jquery: 'jquery/src/jquery'
+ }
},
+
output: {
filename: path.join('_output', uiFolder, '[name].js'),
- sourceMapFilename: path.join('_output', uiFolder, '[file].map')
+ sourceMapFilename: '[file].map'
},
- plugins: [
- new ExtractTextPlugin(path.join('_output', uiFolder, 'Content', 'styles.css'), { allChunks: true }),
- new webpack.optimize.CommonsChunkPlugin({
- name: 'vendor'
- }),
- new webpack.DefinePlugin({
- __DEV__: !isProduction,
- 'process.env': {
- NODE_ENV: isProduction ? JSON.stringify('production') : JSON.stringify('development')
- }
- })
- ],
+
+ plugins,
+
resolveLoader: {
- modulesDirectories: [
+ modules: [
'node_modules',
- 'gulp/webpack/'
+ 'frontend/gulp/webpack/'
]
},
- eslint: {
- formatter: function(results) {
- return JSON.stringify(results);
- }
- },
+
module: {
- loaders: [
+ rules: [
{
test: /\.js?$/,
exclude: /(node_modules|JsLibraries)/,
- loader: 'babel',
+ loader: 'babel-loader',
query: {
plugins: ['transform-class-properties'],
presets: ['es2015', 'decorators-legacy', 'react', 'stage-2'],
@@ -93,51 +117,80 @@ const config = {
{
test: /\.css$/,
exclude: /(node_modules|globals.css)/,
- loader: ExtractTextPlugin.extract('style', 'css-loader?modules&importLoaders=1&sourceMap&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader')
+ use: extractCSSPlugin.extract({
+ fallback: 'style-loader',
+ use: [
+ {
+ loader: 'css-variables-loader',
+ options: {
+ cssVarsFiles
+ }
+ },
+ {
+ loader: 'css-loader',
+ options: {
+ modules: true,
+ importLoaders: 1,
+ localIdentName: '[name]-[local]-[hash:base64:5]',
+ sourceMap: true
+ }
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ config: {
+ ctx: {
+ cssVarsFiles
+ },
+ path: 'frontend/postcss.config.js'
+ }
+ }
+ }
+ ]
+ })
},
// Global styles
{
test: /\.css$/,
include: /(node_modules|globals.css)/,
- loader: 'style!css-loader'
+ use: [
+ 'style-loader',
+ {
+ loader: 'css-loader'
+ }
+ ]
},
// Fonts
{
test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
- loader: 'url?limit=10240&mimetype=application/font-woff&emitFile=false&name=Content/Fonts/[name].[ext]'
+ use: [
+ {
+ loader: 'url-loader',
+ options: {
+ limit: 10240,
+ mimetype: 'application/font-woff',
+ emitFile: false,
+ name: 'Content/Fonts/[name].[ext]'
+ }
+ }
+ ]
},
+
{
test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
- loader: 'file-loader?emitFile=false&name=Content/Fonts/[name].[ext]'
+ use: [
+ {
+ loader: 'file-loader',
+ options: {
+ emitFile: false,
+ name: 'Content/Fonts/[name].[ext]'
+ }
+ }
+ ]
}
]
- },
- postcss: function(wpack) {
- cssVariables.forEach(wpack.addDependency);
-
- return [
- simpleVars({
- variables: function() {
- return cssVariables.reduce(function(obj, vars) {
- return _.extend(obj, reload(vars));
- }, {});
- }
- }),
- nested(),
- autoprefixer({
- browsers: [
- 'Chrome >= 30',
- 'Firefox >= 30',
- 'Safari >= 6',
- 'Edge >= 12',
- 'Explorer >= 10',
- 'iOS >= 7',
- 'Android >= 4.4'
- ]
- })
- ];
}
};
diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/gulp/webpack/css-variables-loader.js
new file mode 100644
index 000000000..5683c98be
--- /dev/null
+++ b/frontend/gulp/webpack/css-variables-loader.js
@@ -0,0 +1,11 @@
+const loaderUtils = require('loader-utils');
+
+module.exports = function cssVariablesLoader(source) {
+ const options = loaderUtils.getOptions(this);
+
+ options.cssVarsFiles.forEach((cssVarsFile) => {
+ this.addDependency(cssVarsFile);
+ });
+
+ return source;
+};
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 000000000..f82554ba8
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,33 @@
+const reload = require('require-nocache')(module);
+
+module.exports = (ctx, configPath, options) => {
+ const config = {
+ plugins: {
+ 'postcss-mixins': {
+ mixinsDir: [
+ 'frontend/src/Styles/Mixins'
+ ]
+ },
+ 'postcss-simple-vars': {
+ variables: () =>
+ ctx.options.cssVarsFiles.reduce((acc, vars) => {
+ return Object.assign(acc, reload(vars));
+ }, {})
+ },
+ 'postcss-nested': {},
+ autoprefixer: {
+ browsers: [
+ 'Chrome >= 30',
+ 'Firefox >= 30',
+ 'Safari >= 6',
+ 'Edge >= 12',
+ 'Explorer >= 11',
+ 'iOS >= 7',
+ 'Android >= 4.4'
+ ]
+ }
+ }
+ };
+
+ return config;
+};
diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json
new file mode 100644
index 000000000..0fb2bf460
--- /dev/null
+++ b/frontend/src/.vscode/settings.json
@@ -0,0 +1,4 @@
+// Place your settings in this file to overwrite default and user settings.
+{
+ "files.insertFinalNewline": true
+}
\ No newline at end of file
diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js
new file mode 100644
index 000000000..e3ecd2ff7
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/Blacklist.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import BlacklistRowConnector from './BlacklistRowConnector';
+
+class Blacklist extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ totalRecords,
+ isClearingBlacklistExecuting,
+ onClearBlacklistPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load blacklist
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No history blacklist
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+ }
+}
+
+Blacklist.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isClearingBlacklistExecuting: PropTypes.bool.isRequired,
+ onClearBlacklistPress: PropTypes.func.isRequired
+};
+
+export default Blacklist;
diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js
new file mode 100644
index 000000000..29cf3e08a
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js
@@ -0,0 +1,145 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as blacklistActions from 'Store/Actions/blacklistActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import Blacklist from './Blacklist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.blacklist,
+ createCommandExecutingSelector(commandNames.CLEAR_BLACKLIST),
+ (blacklist, isClearingBlacklistExecuting) => {
+ return {
+ isClearingBlacklistExecuting,
+ ...blacklist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...blacklistActions,
+ executeCommand
+};
+
+class BlacklistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchBlacklist,
+ gotoBlacklistFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchBlacklist();
+ } else {
+ gotoBlacklistFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearBlacklist();
+ unregisterPagePopulator(this.repopulate);
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchBlacklist();
+ }
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoBlacklistFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoBlacklistPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoBlacklistNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoBlacklistLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoBlacklistPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setBlacklistSort({ sortKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setBlacklistTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ onClearBlacklistPress = () => {
+ this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BlacklistConnector.propTypes = {
+ isClearingBlacklistExecuting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchBlacklist: PropTypes.func.isRequired,
+ gotoBlacklistFirstPage: PropTypes.func.isRequired,
+ gotoBlacklistPreviousPage: PropTypes.func.isRequired,
+ gotoBlacklistNextPage: PropTypes.func.isRequired,
+ gotoBlacklistLastPage: PropTypes.func.isRequired,
+ gotoBlacklistPage: PropTypes.func.isRequired,
+ setBlacklistSort: PropTypes.func.isRequired,
+ setBlacklistTableOption: PropTypes.func.isRequired,
+ clearBlacklist: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector)
+);
diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js
new file mode 100644
index 000000000..356512a9d
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+class BlacklistDetailsModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ sourceTitle,
+ protocol,
+ indexer,
+ message,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+
+ Details
+
+
+
+
+
+
+
+
+ {
+ !!message &&
+
+ }
+
+ {
+ !!message &&
+
+ }
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+BlacklistDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ message: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default BlacklistDetailsModal;
diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css
new file mode 100644
index 000000000..b62d1e750
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.css
@@ -0,0 +1,18 @@
+.language,
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.indexer {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 80px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js
new file mode 100644
index 000000000..47859a80f
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.js
@@ -0,0 +1,186 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import BlacklistDetailsModal from './BlacklistDetailsModal';
+import styles from './BlacklistRow.css';
+
+class BlacklistRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ series,
+ sourceTitle,
+ language,
+ quality,
+ date,
+ protocol,
+ indexer,
+ message,
+ columns,
+ onRemovePress
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'series.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'sourceTitle') {
+ return (
+
+ {sourceTitle}
+
+ );
+ }
+
+ if (name === 'language') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'date') {
+ return (
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+BlacklistRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ series: PropTypes.object.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ language: PropTypes.object.isRequired,
+ quality: PropTypes.object.isRequired,
+ date: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ message: PropTypes.string,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onRemovePress: PropTypes.func.isRequired
+};
+
+export default BlacklistRow;
diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
new file mode 100644
index 000000000..efba18fab
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { removeFromBlacklist } from 'Store/Actions/blacklistActions';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import BlacklistRow from './BlacklistRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ (series) => {
+ return {
+ series
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onRemovePress() {
+ dispatch(removeFromBlacklist({ id: props.id }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(BlacklistRow);
diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css
new file mode 100644
index 000000000..03f8fd3ce
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.css
@@ -0,0 +1,5 @@
+.description {
+ composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
+
+ overflow-wrap: break-word;
+}
diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js
new file mode 100644
index 000000000..928b16bfa
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.js
@@ -0,0 +1,244 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import Link from 'Components/Link/Link';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+import styles from './HistoryDetails.css';
+
+function HistoryDetails(props) {
+ const {
+ eventType,
+ sourceTitle,
+ data,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (eventType === 'grabbed') {
+ const {
+ indexer,
+ releaseGroup,
+ nzbInfoUrl,
+ downloadClient,
+ downloadId,
+ age,
+ ageHours,
+ ageMinutes,
+ publishedDate
+ } = data;
+
+ return (
+
+
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!releaseGroup &&
+
+ }
+
+ {
+ !!nzbInfoUrl &&
+
+
+ Info URL
+
+
+
+ {nzbInfoUrl}
+
+
+ }
+
+ {
+ !!downloadClient &&
+
+ }
+
+ {
+ !!downloadId &&
+
+ }
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!publishedDate &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'downloadFailed') {
+ const {
+ message
+ } = data;
+
+ return (
+
+
+
+ {
+ !!message &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'downloadFolderImported') {
+ const {
+ droppedPath,
+ importedPath
+ } = data;
+
+ return (
+
+
+
+ {
+ !!droppedPath &&
+
+ }
+
+ {
+ !!importedPath &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'episodeFileDeleted') {
+ const {
+ reason
+ } = data;
+
+ let reasonMessage = '';
+
+ switch (reason) {
+ case 'Manual':
+ reasonMessage = 'File was deleted by via UI';
+ break;
+ case 'MissingFromDisk':
+ reasonMessage = 'Sonarr was unable to find the file on disk so it was removed';
+ break;
+ case 'Upgrade':
+ reasonMessage = 'File was deleted to import an upgrade';
+ break;
+ default:
+ reasonMessage = '';
+ }
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (eventType === 'episodeFileRenamed') {
+ const {
+ sourcePath,
+ sourceRelativePath,
+ path,
+ relativePath
+ } = data;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+HistoryDetails.propTypes = {
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default HistoryDetails;
diff --git a/frontend/src/Activity/History/Details/HistoryDetailsAge.js b/frontend/src/Activity/History/Details/HistoryDetailsAge.js
new file mode 100644
index 000000000..c702014ce
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetailsAge.js
@@ -0,0 +1,21 @@
+var Handlebars = require('handlebars');
+var FormatHelpers = require('Shared/FormatHelpers');
+
+Handlebars.registerHelper('historyAge', function() {
+ var age = this.age;
+ var unit = FormatHelpers.plural(Math.round(age), 'day');
+ var ageHours = parseFloat(this.ageHours);
+ var ageMinutes = this.ageMinutes ? parseFloat(this.ageMinutes) : null;
+
+ if (age < 2) {
+ age = ageHours.toFixed(1);
+ unit = FormatHelpers.plural(Math.round(ageHours), 'hour');
+ }
+
+ if (age < 2 && ageMinutes) {
+ age = parseFloat(ageMinutes).toFixed(1);
+ unit = FormatHelpers.plural(Math.round(ageMinutes), 'minute');
+ }
+
+ return new Handlebars.SafeString(`
Age (when grabbed): ${age} ${unit} `);
+});
diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js
new file mode 100644
index 000000000..0848c7905
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js
@@ -0,0 +1,19 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import HistoryDetails from './HistoryDetails';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return _.pick(uiSettings, [
+ 'shortDateFormat',
+ 'timeFormat'
+ ]);
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(HistoryDetails);
diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css b/frontend/src/Activity/History/Details/HistoryDetailsModal.css
new file mode 100644
index 000000000..bdcb7f918
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css
@@ -0,0 +1,5 @@
+.markAsFailedButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js
new file mode 100644
index 000000000..2cf9294f6
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js
@@ -0,0 +1,104 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Modal from 'Components/Modal/Modal';
+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 HistoryDetails from './HistoryDetails';
+import styles from './HistoryDetailsModal.css';
+
+function getHeaderTitle(eventType) {
+ switch (eventType) {
+ case 'grabbed':
+ return 'Grabbed';
+ case 'downloadFailed':
+ return 'Download Failed';
+ case 'downloadFolderImported':
+ return 'Episode Imported';
+ case 'episodeFileDeleted':
+ return 'Episode File Deleted';
+ case 'episodeFileRenamed':
+ return 'Episode File Renamed';
+ default:
+ return 'Unknown';
+ }
+}
+
+function HistoryDetailsModal(props) {
+ const {
+ isOpen,
+ eventType,
+ sourceTitle,
+ data,
+ isMarkingAsFailed,
+ shortDateFormat,
+ timeFormat,
+ onMarkAsFailedPress,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ {getHeaderTitle(eventType)}
+
+
+
+
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ Mark as Failed
+
+ }
+
+
+ Close
+
+
+
+
+ );
+}
+
+HistoryDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ isMarkingAsFailed: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+HistoryDetailsModal.defaultProps = {
+ isMarkingAsFailed: false
+};
+
+export default HistoryDetailsModal;
diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js
new file mode 100644
index 000000000..75f504d22
--- /dev/null
+++ b/frontend/src/Activity/History/History.js
@@ -0,0 +1,161 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons } from 'Helpers/Props';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import HistoryRowConnector from './HistoryRowConnector';
+
+class History extends Component {
+
+ //
+ // Lifecycle
+
+ shouldComponentUpdate(nextProps) {
+ // Don't update when fetching has completed if items have changed,
+ // before episodes start fetching or when episodes start fetching.
+
+ if (
+ (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items)
+ ) ||
+ (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ selectedFilterKey,
+ filters,
+ totalRecords,
+ isEpisodesFetching,
+ isEpisodesPopulated,
+ episodesError,
+ onFilterSelect,
+ onFirstPagePress,
+ ...otherProps
+ } = this.props;
+
+ const isFetchingAny = isFetching || isEpisodesFetching;
+ const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
+ const hasError = error || episodesError;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetchingAny && !isAllPopulated &&
+
+ }
+
+ {
+ !isFetchingAny && hasError &&
+ Unable to load history
+ }
+
+ {
+ // If history isPopulated and it's empty show no history found and don't
+ // wait for the episodes to populate because they are never coming.
+
+ isPopulated && !hasError && !items.length &&
+
+ No history found
+
+ }
+
+ {
+ isAllPopulated && !hasError && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+ }
+}
+
+History.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isEpisodesFetching: PropTypes.bool.isRequired,
+ isEpisodesPopulated: PropTypes.bool.isRequired,
+ episodesError: PropTypes.object,
+ onFilterSelect: PropTypes.func.isRequired,
+ onFirstPagePress: PropTypes.func.isRequired
+};
+
+export default History;
diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js
new file mode 100644
index 000000000..35591f258
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryConnector.js
@@ -0,0 +1,157 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import * as historyActions from 'Store/Actions/historyActions';
+import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
+import History from './History';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.history,
+ (state) => state.episodes,
+ (history, episodes) => {
+ return {
+ isEpisodesFetching: episodes.isFetching,
+ isEpisodesPopulated: episodes.isPopulated,
+ episodesError: episodes.error,
+ ...history
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...historyActions,
+ fetchEpisodes,
+ clearEpisodes
+};
+
+class HistoryConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchHistory,
+ gotoHistoryFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchHistory();
+ } else {
+ gotoHistoryFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const episodeIds = selectUniqueIds(this.props.items, 'episodeId');
+
+ if (episodeIds.length) {
+ this.props.fetchEpisodes({ episodeIds });
+ } else {
+ this.props.clearEpisodes();
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearHistory();
+ this.props.clearEpisodes();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchHistory();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoHistoryFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoHistoryPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoHistoryNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoHistoryLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoHistoryPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setHistorySort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setHistoryFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setHistoryTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoHistoryFirstPage();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HistoryConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchHistory: PropTypes.func.isRequired,
+ gotoHistoryFirstPage: PropTypes.func.isRequired,
+ gotoHistoryPreviousPage: PropTypes.func.isRequired,
+ gotoHistoryNextPage: PropTypes.func.isRequired,
+ gotoHistoryLastPage: PropTypes.func.isRequired,
+ gotoHistoryPage: PropTypes.func.isRequired,
+ setHistorySort: PropTypes.func.isRequired,
+ setHistoryFilter: PropTypes.func.isRequired,
+ setHistoryTableOption: PropTypes.func.isRequired,
+ clearHistory: PropTypes.func.isRequired,
+ fetchEpisodes: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector)
+);
diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css
new file mode 100644
index 000000000..fac97a6c7
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryEventTypeCell.css
@@ -0,0 +1,6 @@
+.cell {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 35px;
+ text-align: center;
+}
diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js
new file mode 100644
index 000000000..f013b3f55
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryEventTypeCell.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './HistoryEventTypeCell.css';
+
+function getIconName(eventType) {
+ switch (eventType) {
+ case 'grabbed':
+ return icons.DOWNLOADING;
+ case 'seriesFolderImported':
+ return icons.DRIVE;
+ case 'downloadFolderImported':
+ return icons.DOWNLOADED;
+ case 'downloadFailed':
+ return icons.DOWNLOADING;
+ case 'episodeFileDeleted':
+ return icons.DELETE;
+ case 'episodeFileRenamed':
+ return icons.ORGANIZE;
+ default:
+ return icons.UNKNOWN;
+ }
+}
+
+function getIconKind(eventType) {
+ switch (eventType) {
+ case 'downloadFailed':
+ return kinds.DANGER;
+ default:
+ return kinds.DEFAULT;
+ }
+}
+
+function getTooltip(eventType, data) {
+ switch (eventType) {
+ case 'grabbed':
+ return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
+ case 'seriesFolderImported':
+ return 'Episode imported from series folder';
+ case 'downloadFolderImported':
+ return 'Episode downloaded successfully and picked up from download client';
+ case 'downloadFailed':
+ return 'Episode download failed';
+ case 'episodeFileDeleted':
+ return 'Episode file deleted';
+ case 'episodeFileRenamed':
+ return 'Episode file renamed';
+ default:
+ return 'Unknown event';
+ }
+}
+
+function HistoryEventTypeCell({ eventType, data }) {
+ const iconName = getIconName(eventType);
+ const iconKind = getIconKind(eventType);
+ const tooltip = getTooltip(eventType, data);
+
+ return (
+
+
+
+ );
+}
+
+HistoryEventTypeCell.propTypes = {
+ eventType: PropTypes.string.isRequired,
+ data: PropTypes.object
+};
+
+HistoryEventTypeCell.defaultProps = {
+ data: {}
+};
+
+export default HistoryEventTypeCell;
diff --git a/frontend/src/Activity/History/HistoryRow.css b/frontend/src/Activity/History/HistoryRow.css
new file mode 100644
index 000000000..83586af58
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRow.css
@@ -0,0 +1,23 @@
+.downloadClient {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 120px;
+}
+
+.indexer {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 80px;
+}
+
+.releaseGroup {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 110px;
+}
+
+.details {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js
new file mode 100644
index 000000000..454913412
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRow.js
@@ -0,0 +1,263 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import episodeEntities from 'Episode/episodeEntities';
+import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
+import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import HistoryEventTypeCell from './HistoryEventTypeCell';
+import HistoryDetailsModal from './Details/HistoryDetailsModal';
+import styles from './HistoryRow.css';
+
+class HistoryRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.isMarkingAsFailed &&
+ !this.props.isMarkingAsFailed &&
+ !this.props.markAsFailedError
+ ) {
+ this.setState({ isDetailsModalOpen: false });
+ }
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ episodeId,
+ series,
+ episode,
+ language,
+ languageCutoffNotMet,
+ quality,
+ qualityCutoffNotMet,
+ eventType,
+ sourceTitle,
+ date,
+ data,
+ isMarkingAsFailed,
+ columns,
+ shortDateFormat,
+ timeFormat,
+ onMarkAsFailedPress
+ } = this.props;
+
+ if (!episode) {
+ return null;
+ }
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'eventType') {
+ return (
+
+ );
+ }
+
+ if (name === 'series.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episode') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'language') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'date') {
+ return (
+
+ );
+ }
+
+ if (name === 'downloadClient') {
+ return (
+
+ {data.downloadClient}
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {data.indexer}
+
+ );
+ }
+
+ if (name === 'releaseGroup') {
+ return (
+
+ {data.releaseGroup}
+
+ );
+ }
+
+ if (name === 'details') {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+HistoryRow.propTypes = {
+ episodeId: PropTypes.number,
+ series: PropTypes.object.isRequired,
+ episode: PropTypes.object,
+ language: PropTypes.object.isRequired,
+ languageCutoffNotMet: PropTypes.bool.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ isMarkingAsFailed: PropTypes.bool,
+ markAsFailedError: PropTypes.object,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+export default HistoryRow;
diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js
new file mode 100644
index 000000000..271000193
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRowConnector.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import HistoryRow from './HistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ createEpisodeSelector(),
+ createUISettingsSelector(),
+ (series, episode, uiSettings) => {
+ return {
+ series,
+ episode,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHistory,
+ markAsFailed
+};
+
+class HistoryRowConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.isMarkingAsFailed &&
+ !this.props.isMarkingAsFailed &&
+ !this.props.markAsFailedError
+ ) {
+ this.props.fetchHistory();
+ }
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = () => {
+ this.props.markAsFailed({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+
+}
+
+HistoryRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isMarkingAsFailed: PropTypes.bool,
+ markAsFailedError: PropTypes.object,
+ fetchHistory: PropTypes.func.isRequired,
+ markAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector);
diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css
new file mode 100644
index 000000000..15e8e4fc6
--- /dev/null
+++ b/frontend/src/Activity/Queue/ProtocolLabel.css
@@ -0,0 +1,13 @@
+.torrent {
+ composes: label from 'Components/Label.css';
+
+ border-color: $torrentColor;
+ background-color: $torrentColor;
+}
+
+.usenet {
+ composes: label from 'Components/Label.css';
+
+ border-color: $usenetColor;
+ background-color: $usenetColor;
+}
diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js
new file mode 100644
index 000000000..e8a08943c
--- /dev/null
+++ b/frontend/src/Activity/Queue/ProtocolLabel.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Label from 'Components/Label';
+import styles from './ProtocolLabel.css';
+
+function ProtocolLabel({ protocol }) {
+ const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
+
+ return (
+
+ {protocolName}
+
+ );
+}
+
+ProtocolLabel.propTypes = {
+ protocol: PropTypes.string.isRequired
+};
+
+export default ProtocolLabel;
diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js
new file mode 100644
index 000000000..9aadc828b
--- /dev/null
+++ b/frontend/src/Activity/Queue/Queue.js
@@ -0,0 +1,266 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { icons } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import RemoveQueueItemsModal from './RemoveQueueItemsModal';
+import QueueOptionsConnector from './QueueOptionsConnector';
+import QueueRowConnector from './QueueRowConnector';
+
+class Queue extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isPendingSelected: false,
+ isConfirmRemoveModalOpen: false
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ // Don't update when fetching has completed if items have changed,
+ // before episodes start fetching or when episodes start fetching.
+
+ if (
+ (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items)
+ ) ||
+ (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState({ selectedState: {} });
+ return;
+ }
+
+ const selectedIds = this.getSelectedIds();
+ const isPendingSelected = _.some(this.props.items, (item) => {
+ return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay';
+ });
+
+ if (isPendingSelected !== this.state.isPendingSelected) {
+ this.setState({ isPendingSelected });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onGrabSelectedPress = () => {
+ this.props.onGrabSelectedPress(this.getSelectedIds());
+ }
+
+ onRemoveSelectedPress = () => {
+ this.setState({ isConfirmRemoveModalOpen: true });
+ }
+
+ onRemoveSelectedConfirmed = (blacklist) => {
+ this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist);
+ this.setState({ isConfirmRemoveModalOpen: false });
+ }
+
+ onConfirmRemoveModalClose = () => {
+ this.setState({ isConfirmRemoveModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isEpisodesFetching,
+ isEpisodesPopulated,
+ episodesError,
+ columns,
+ totalRecords,
+ isGrabbing,
+ isRemoving,
+ isCheckForFinishedDownloadExecuting,
+ onRefreshPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmRemoveModalOpen,
+ isPendingSelected
+ } = this.state;
+
+ const isRefreshing = isFetching || isEpisodesFetching || isCheckForFinishedDownloadExecuting;
+ const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
+ const hasError = error || episodesError;
+ const selectedCount = this.getSelectedIds().length;
+ const disableSelectedActions = selectedCount === 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isRefreshing && !isAllPopulated &&
+
+ }
+
+ {
+ !isRefreshing && hasError &&
+
+ Failed to load Queue
+
+ }
+
+ {
+ isPopulated && !hasError && !items.length &&
+
+ Queue is empty
+
+ }
+
+ {
+ isAllPopulated && !hasError && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+
+
+ );
+ }
+}
+
+Queue.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isEpisodesFetching: PropTypes.bool.isRequired,
+ isEpisodesPopulated: PropTypes.bool.isRequired,
+ episodesError: PropTypes.object,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isGrabbing: PropTypes.bool.isRequired,
+ isRemoving: PropTypes.bool.isRequired,
+ isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onGrabSelectedPress: PropTypes.func.isRequired,
+ onRemoveSelectedPress: PropTypes.func.isRequired
+};
+
+export default Queue;
diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js
new file mode 100644
index 000000000..c598bbea5
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueConnector.js
@@ -0,0 +1,186 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as queueActions from 'Store/Actions/queueActions';
+import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
+import * as commandNames from 'Commands/commandNames';
+import Queue from './Queue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.episodes,
+ (state) => state.queue.options,
+ (state) => state.queue.paged,
+ createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD),
+ (episodes, options, queue, isCheckForFinishedDownloadExecuting) => {
+ return {
+ isEpisodesFetching: episodes.isFetching,
+ isEpisodesPopulated: episodes.isPopulated,
+ episodesError: episodes.error,
+ isCheckForFinishedDownloadExecuting,
+ ...options,
+ ...queue
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...queueActions,
+ fetchEpisodes,
+ clearEpisodes,
+ executeCommand
+};
+
+class QueueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchQueue,
+ gotoQueueFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchQueue();
+ } else {
+ gotoQueueFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const episodeIds = selectUniqueIds(this.props.items, 'episodeId');
+
+ if (episodeIds.length) {
+ this.props.fetchEpisodes({ episodeIds });
+ } else {
+ this.props.clearEpisodes();
+ }
+ }
+
+ if (
+ this.props.includeUnknownSeriesItems !==
+ prevProps.includeUnknownSeriesItems
+ ) {
+ this.repopulate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearQueue();
+ this.props.clearEpisodes();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchQueue();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoQueueFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoQueuePreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoQueueNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoQueueLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoQueuePage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setQueueSort({ sortKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setQueueTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoQueueFirstPage();
+ }
+ }
+
+ onRefreshPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD
+ });
+ }
+
+ onGrabSelectedPress = (ids) => {
+ this.props.grabQueueItems({ ids });
+ }
+
+ onRemoveSelectedPress = (ids, blacklist) => {
+ this.props.removeQueueItems({ ids, blacklist });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchQueue: PropTypes.func.isRequired,
+ gotoQueueFirstPage: PropTypes.func.isRequired,
+ gotoQueuePreviousPage: PropTypes.func.isRequired,
+ gotoQueueNextPage: PropTypes.func.isRequired,
+ gotoQueueLastPage: PropTypes.func.isRequired,
+ gotoQueuePage: PropTypes.func.isRequired,
+ setQueueSort: PropTypes.func.isRequired,
+ setQueueTableOption: PropTypes.func.isRequired,
+ clearQueue: PropTypes.func.isRequired,
+ grabQueueItems: PropTypes.func.isRequired,
+ removeQueueItems: PropTypes.func.isRequired,
+ fetchEpisodes: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(QueueConnector)
+);
diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js
new file mode 100644
index 000000000..f6e360c0a
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueDetails.js
@@ -0,0 +1,97 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+
+function QueueDetails(props) {
+ const {
+ title,
+ size,
+ sizeleft,
+ estimatedCompletionTime,
+ status: queueStatus,
+ errorMessage,
+ progressBar
+ } = props;
+
+ const status = queueStatus.toLowerCase();
+
+ const progress = (100 - sizeleft / size * 100);
+
+ if (status === 'pending') {
+ return (
+
+ );
+ }
+
+ if (status === 'completed') {
+ if (errorMessage) {
+ return (
+
+ );
+ }
+
+ // TODO: show an icon when download is complete, but not imported yet?
+ }
+
+ if (errorMessage) {
+ return (
+
+ );
+ }
+
+ if (status === 'failed') {
+ return (
+
+ );
+ }
+
+ if (status === 'warning') {
+ return (
+
+ );
+ }
+
+ if (progress < 5) {
+ return (
+
+ );
+ }
+
+ return progressBar;
+}
+
+QueueDetails.propTypes = {
+ title: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ estimatedCompletionTime: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ errorMessage: PropTypes.string,
+ progressBar: PropTypes.node.isRequired
+};
+
+export default QueueDetails;
diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js
new file mode 100644
index 000000000..900cf85cb
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueOptions.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } 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';
+
+class QueueOptions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ includeUnknownSeriesItems: props.includeUnknownSeriesItems
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ includeUnknownSeriesItems
+ } = this.props;
+
+ if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) {
+ this.setState({
+ includeUnknownSeriesItems
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onOptionChange = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onOptionChange({
+ [name]: value
+ });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ includeUnknownSeriesItems
+ } = this.state;
+
+ return (
+
+
+ Show Unknown Series Items
+
+
+
+
+ );
+ }
+}
+
+QueueOptions.propTypes = {
+ includeUnknownSeriesItems: PropTypes.bool.isRequired,
+ onOptionChange: PropTypes.func.isRequired
+};
+
+export default QueueOptions;
diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js
new file mode 100644
index 000000000..b2c99511c
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setQueueOption } from 'Store/Actions/queueActions';
+import QueueOptions from './QueueOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.queue.options,
+ (options) => {
+ return options;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onOptionChange: setQueueOption
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions);
diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css
new file mode 100644
index 000000000..6aa4a1622
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.css
@@ -0,0 +1,23 @@
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.protocol {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.progress {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js
new file mode 100644
index 000000000..1d5610168
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.js
@@ -0,0 +1,369 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ProgressBar from 'Components/ProgressBar';
+import TableRow from 'Components/Table/TableRow';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import QueueStatusCell from './QueueStatusCell';
+import TimeleftCell from './TimeleftCell';
+import RemoveQueueItemModal from './RemoveQueueItemModal';
+import styles from './QueueRow.css';
+
+class QueueRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRemoveQueueItemModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRemoveQueueItemPress = () => {
+ this.setState({ isRemoveQueueItemModalOpen: true });
+ }
+
+ onRemoveQueueItemModalConfirmed = (blacklist) => {
+ this.props.onRemoveQueueItemPress(blacklist);
+ this.setState({ isRemoveQueueItemModalOpen: false });
+ }
+
+ onRemoveQueueItemModalClose = () => {
+ this.setState({ isRemoveQueueItemModalOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ downloadId,
+ title,
+ status,
+ trackedDownloadStatus,
+ statusMessages,
+ errorMessage,
+ series,
+ episode,
+ quality,
+ protocol,
+ indexer,
+ downloadClient,
+ estimatedCompletionTime,
+ timeleft,
+ size,
+ sizeleft,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isGrabbing,
+ grabError,
+ isRemoving,
+ isSelected,
+ columns,
+ onSelectedChange,
+ onGrabPress
+ } = this.props;
+
+ const {
+ isRemoveQueueItemModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const progress = 100 - (sizeleft / size * 100);
+ const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning';
+ const isPending = status === 'Delay' || status === 'DownloadClientUnavailable';
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'status') {
+ return (
+
+ );
+ }
+
+ if (name === 'series.sortTitle') {
+ return (
+
+ {
+ series ?
+ :
+ title
+ }
+
+ );
+ }
+
+ if (name === 'episode') {
+ return (
+
+ {
+ episode ?
+ :
+ '-'
+ }
+
+ );
+ }
+
+ if (name === 'episode.title') {
+ return (
+
+ {
+ episode ?
+ :
+ '-'
+ }
+
+ );
+ }
+
+ if (name === 'episode.airDateUtc') {
+ if (episode) {
+ return (
+
+ );
+ }
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'protocol') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'downloadClient') {
+ return (
+
+ {downloadClient}
+
+ );
+ }
+
+ if (name === 'estimatedCompletionTime') {
+ return (
+
+ );
+ }
+
+ if (name === 'progress') {
+ return (
+
+ {
+ !!progress &&
+
+ }
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ {
+ showInteractiveImport &&
+
+ }
+
+ {
+ isPending &&
+
+ }
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+
+}
+
+QueueRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ downloadId: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ status: PropTypes.string.isRequired,
+ trackedDownloadStatus: PropTypes.string,
+ statusMessages: PropTypes.arrayOf(PropTypes.object),
+ errorMessage: PropTypes.string,
+ series: PropTypes.object,
+ episode: PropTypes.object,
+ quality: PropTypes.object.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ downloadClient: PropTypes.string,
+ estimatedCompletionTime: PropTypes.string,
+ timeleft: PropTypes.string,
+ size: PropTypes.number,
+ sizeleft: PropTypes.number,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isGrabbing: PropTypes.bool.isRequired,
+ grabError: PropTypes.object,
+ isRemoving: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired,
+ onGrabPress: PropTypes.func.isRequired,
+ onRemoveQueueItemPress: PropTypes.func.isRequired
+};
+
+QueueRow.defaultProps = {
+ isGrabbing: false,
+ isRemoving: false
+};
+
+export default QueueRow;
diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js
new file mode 100644
index 000000000..10442e30f
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRowConnector.js
@@ -0,0 +1,71 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import QueueRow from './QueueRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ createEpisodeSelector(),
+ createUISettingsSelector(),
+ (series, episode, uiSettings) => {
+ const result = _.pick(uiSettings, [
+ 'showRelativeDates',
+ 'shortDateFormat',
+ 'timeFormat'
+ ]);
+
+ result.series = series;
+ result.episode = episode;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ grabQueueItem,
+ removeQueueItem
+};
+
+class QueueRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ this.props.grabQueueItem({ id: this.props.id });
+ }
+
+ onRemoveQueueItemPress = (blacklist) => {
+ this.props.removeQueueItem({ id: this.props.id, blacklist });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ episode: PropTypes.object,
+ grabQueueItem: PropTypes.func.isRequired,
+ removeQueueItem: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector);
diff --git a/frontend/src/Activity/Queue/QueueStatusCell.css b/frontend/src/Activity/Queue/QueueStatusCell.css
new file mode 100644
index 000000000..6291ec949
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueStatusCell.css
@@ -0,0 +1,5 @@
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js
new file mode 100644
index 000000000..f8cbc65ff
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueStatusCell.js
@@ -0,0 +1,132 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import styles from './QueueStatusCell.css';
+
+function getDetailedPopoverBody(statusMessages) {
+ return (
+
+ {
+ statusMessages.map(({ title, messages }) => {
+ return (
+
+ {title}
+
+ {
+ messages.map((message) => {
+ return (
+
+ {message}
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+
+ );
+}
+
+function QueueStatusCell(props) {
+ const {
+ sourceTitle,
+ status,
+ trackedDownloadStatus = 'Ok',
+ statusMessages,
+ errorMessage
+ } = props;
+
+ const hasWarning = trackedDownloadStatus === 'Warning';
+ const hasError = trackedDownloadStatus === 'Error';
+
+ // status === 'downloading'
+ let iconName = icons.DOWNLOADING;
+ let iconKind = kinds.DEFAULT;
+ let title = 'Downloading';
+
+ if (hasWarning) {
+ iconKind = kinds.WARNING;
+ }
+
+ if (status === 'Paused') {
+ iconName = icons.PAUSED;
+ title = 'Paused';
+ }
+
+ if (status === 'Queued') {
+ iconName = icons.QUEUED;
+ title = 'Queued';
+ }
+
+ if (status === 'Completed') {
+ iconName = icons.DOWNLOADED;
+ title = 'Downloaded';
+ }
+
+ if (status === 'Delay') {
+ iconName = icons.PENDING;
+ title = 'Pending';
+ }
+
+ if (status === 'DownloadClientUnavailable') {
+ iconName = icons.PENDING;
+ iconKind = kinds.WARNING;
+ title = 'Pending - Download client is unavailable';
+ }
+
+ if (status === 'Failed') {
+ iconName = icons.DOWNLOADING;
+ iconKind = kinds.DANGER;
+ title = 'Download failed';
+ }
+
+ if (status === 'Warning') {
+ iconName = icons.DOWNLOADING;
+ iconKind = kinds.WARNING;
+ title = `Download warning: ${errorMessage || 'check download client for more details'}`;
+ }
+
+ if (hasError) {
+ if (status === 'Completed') {
+ iconName = icons.DOWNLOAD;
+ iconKind = kinds.DANGER;
+ title = `Import failed: ${sourceTitle}`;
+ } else {
+ iconName = icons.DOWNLOADING;
+ iconKind = kinds.DANGER;
+ title = 'Download failed';
+ }
+ }
+
+ return (
+
+
+ }
+ title={title}
+ body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
+ position={tooltipPositions.RIGHT}
+ />
+
+ );
+}
+
+QueueStatusCell.propTypes = {
+ sourceTitle: PropTypes.string.isRequired,
+ status: PropTypes.string.isRequired,
+ trackedDownloadStatus: PropTypes.string,
+ statusMessages: PropTypes.arrayOf(PropTypes.object),
+ errorMessage: PropTypes.string
+};
+
+export default QueueStatusCell;
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css
new file mode 100644
index 000000000..c9ef59ec1
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css
@@ -0,0 +1,3 @@
+.message {
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js
new file mode 100644
index 000000000..52c2bc1cc
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+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 styles from './RemoveQueueItemModal.css';
+
+class RemoveQueueItemModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ blacklist: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onBlacklistChange = ({ value }) => {
+ this.setState({ blacklist: value });
+ }
+
+ onRemoveQueueItemConfirmed = () => {
+ const blacklist = this.state.blacklist;
+
+ this.setState({ blacklist: false });
+ this.props.onRemovePress(blacklist);
+ }
+
+ onModalClose = () => {
+ this.setState({ blacklist: false });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ sourceTitle
+ } = this.props;
+
+ const blacklist = this.state.blacklist;
+
+ return (
+
+
+
+ Remove - {sourceTitle}
+
+
+
+
+ Are you sure you want to remove '{sourceTitle}' from the queue?
+
+
+
+ Blacklist Release
+
+
+
+
+
+
+
+ Close
+
+
+
+ Remove
+
+
+
+
+ );
+ }
+}
+
+RemoveQueueItemModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ onRemovePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RemoveQueueItemModal;
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css
new file mode 100644
index 000000000..c9ef59ec1
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css
@@ -0,0 +1,3 @@
+.message {
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js
new file mode 100644
index 000000000..8e8009ab1
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+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 styles from './RemoveQueueItemsModal.css';
+
+class RemoveQueueItemsModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ blacklist: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onBlacklistChange = ({ value }) => {
+ this.setState({ blacklist: value });
+ }
+
+ onRemoveQueueItemConfirmed = () => {
+ const blacklist = this.state.blacklist;
+
+ this.setState({ blacklist: false });
+ this.props.onRemovePress(blacklist);
+ }
+
+ onModalClose = () => {
+ this.setState({ blacklist: false });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ selectedCount
+ } = this.props;
+
+ const blacklist = this.state.blacklist;
+
+ return (
+
+
+
+ Remove Selected Item{selectedCount > 1 ? 's' : ''}
+
+
+
+
+ Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue?
+
+
+
+ Blacklist Release
+
+
+
+
+
+
+
+ Close
+
+
+
+ Remove
+
+
+
+
+ );
+ }
+}
+
+RemoveQueueItemsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ selectedCount: PropTypes.number.isRequired,
+ onRemovePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RemoveQueueItemsModal;
diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js
new file mode 100644
index 000000000..0f6c9159d
--- /dev/null
+++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQueueStatus } from 'Store/Actions/queueActions';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ (state) => state.queue.status,
+ (state) => state.queue.options.includeUnknownSeriesItems,
+ (app, status, includeUnknownSeriesItems) => {
+ const {
+ count,
+ unknownCount
+ } = status.item;
+
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: status.isPopulated,
+ ...status.item,
+ count: includeUnknownSeriesItems ? count : count - unknownCount
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQueueStatus
+};
+
+class QueueStatusConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.fetchQueueStatus();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.isConnected && prevProps.isReconnecting) {
+ this.props.fetchQueueStatus();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueStatusConnector.propTypes = {
+ isConnected: PropTypes.bool.isRequired,
+ isReconnecting: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchQueueStatus: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector);
diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css
new file mode 100644
index 000000000..eb58cf297
--- /dev/null
+++ b/frontend/src/Activity/Queue/TimeleftCell.css
@@ -0,0 +1,5 @@
+.timeleft {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js
new file mode 100644
index 000000000..c9515f172
--- /dev/null
+++ b/frontend/src/Activity/Queue/TimeleftCell.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatTime from 'Utilities/Date/formatTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './TimeleftCell.css';
+
+function TimeleftCell(props) {
+ const {
+ estimatedCompletionTime,
+ timeleft,
+ status,
+ size,
+ sizeleft,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (status === 'Delay') {
+ const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
+ const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (status === 'DownloadClientUnavailable') {
+ const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
+ const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (!timeleft) {
+ return (
+
+ -
+
+ );
+ }
+
+ const totalSize = formatBytes(size);
+ const remainingSize = formatBytes(sizeleft);
+
+ return (
+
+ {formatTimeSpan(timeleft)}
+
+ );
+}
+
+TimeleftCell.propTypes = {
+ estimatedCompletionTime: PropTypes.string,
+ timeleft: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default TimeleftCell;
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css
new file mode 100644
index 000000000..0bf8b0e15
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css
@@ -0,0 +1,54 @@
+.searchContainer {
+ display: flex;
+ margin-bottom: 10px;
+}
+
+.searchIconContainer {
+ width: 58px;
+ height: 46px;
+ border: 1px solid $inputBorderColor;
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: #edf1f2;
+ text-align: center;
+ line-height: 46px;
+}
+
+.searchInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ height: 46px;
+ border-radius: 0;
+ font-size: 18px;
+}
+
+.clearLookupButton {
+ border: 1px solid $inputBorderColor;
+ border-left: none;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+
+.message {
+ margin-top: 30px;
+ text-align: center;
+}
+
+.helpText {
+ margin-bottom: 10px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.noResults {
+ margin-bottom: 10px;
+ font-weight: 300;
+ font-size: 30px;
+}
+
+.searchResults {
+ margin-top: 30px;
+}
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js
new file mode 100644
index 000000000..4c389e940
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js
@@ -0,0 +1,182 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import TextInput from 'Components/Form/TextInput';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
+import styles from './AddNewSeries.css';
+
+class AddNewSeries extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ term: props.term || '',
+ isFetching: false
+ };
+ }
+
+ componentDidMount() {
+ const term = this.state.term;
+
+ if (term) {
+ this.props.onSeriesLookupChange(term);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ term,
+ isFetching
+ } = this.props;
+
+ if (term && term !== prevProps.term) {
+ this.setState({
+ term,
+ isFetching: true
+ });
+ this.props.onSeriesLookupChange(term);
+ } else if (isFetching !== prevProps.isFetching) {
+ this.setState({
+ isFetching
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onSearchInputChange = ({ value }) => {
+ const hasValue = !!value.trim();
+
+ this.setState({ term: value, isFetching: hasValue }, () => {
+ if (hasValue) {
+ this.props.onSeriesLookupChange(value);
+ } else {
+ this.props.onClearSeriesLookup();
+ }
+ });
+ }
+
+ onClearSeriesLookupPress = () => {
+ this.setState({ term: '' });
+ this.props.onClearSeriesLookup();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ error,
+ items
+ } = this.props;
+
+ const term = this.state.term;
+ const isFetching = this.state.isFetching;
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Failed to load search results, please try again.
+ }
+
+ {
+ !isFetching && !error && !!items.length &&
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !isFetching && !error && !items.length && !!term &&
+
+
Couldn't find any results for '{term}'
+
You can also search using TVDB ID of a show. eg. tvdb:71663
+
+
+ Why can't I find my show?
+
+
+
+ }
+
+ {
+ !term &&
+
+
It's easy to add a new series, just start typing the name the series you want to add.
+
You can also search using TVDB ID of a show. eg. tvdb:71663
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AddNewSeries.propTypes = {
+ term: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isAdding: PropTypes.bool.isRequired,
+ addError: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSeriesLookupChange: PropTypes.func.isRequired,
+ onClearSeriesLookup: PropTypes.func.isRequired
+};
+
+export default AddNewSeries;
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js
new file mode 100644
index 000000000..9374fb54e
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import parseUrl from 'Utilities/String/parseUrl';
+import { lookupSeries, clearAddSeries } from 'Store/Actions/addSeriesActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import AddNewSeries from './AddNewSeries';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addSeries,
+ (state) => state.routing.location,
+ (addSeries, location) => {
+ const { params } = parseUrl(location.search);
+
+ return {
+ term: params.term,
+ ...addSeries
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ lookupSeries,
+ clearAddSeries,
+ fetchRootFolders
+};
+
+class AddNewSeriesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._seriesLookupTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ componentWillUnmount() {
+ if (this._seriesLookupTimeout) {
+ clearTimeout(this._seriesLookupTimeout);
+ }
+
+ this.props.clearAddSeries();
+ }
+
+ //
+ // Listeners
+
+ onSeriesLookupChange = (term) => {
+ if (this._seriesLookupTimeout) {
+ clearTimeout(this._seriesLookupTimeout);
+ }
+
+ if (term.trim() === '') {
+ this.props.clearAddSeries();
+ } else {
+ this._seriesLookupTimeout = setTimeout(() => {
+ this.props.lookupSeries({ term });
+ }, 300);
+ }
+ }
+
+ onClearSeriesLookup = () => {
+ this.props.clearAddSeries();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ term,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+AddNewSeriesConnector.propTypes = {
+ term: PropTypes.string,
+ lookupSeries: PropTypes.func.isRequired,
+ clearAddSeries: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js
new file mode 100644
index 000000000..cb603e7a6
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector';
+
+function AddNewSeriesModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AddNewSeriesModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNewSeriesModal;
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css
new file mode 100644
index 000000000..a58e22b53
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css
@@ -0,0 +1,74 @@
+.container {
+ display: flex;
+}
+
+.year {
+ margin-left: 5px;
+ color: $disabledColor;
+}
+
+.poster {
+ flex: 0 0 170px;
+ margin-right: 20px;
+ height: 250px;
+}
+
+.info {
+ flex-grow: 1;
+}
+
+.overview {
+ margin-bottom: 30px;
+}
+
+.labelIcon {
+ margin-left: 8px;
+}
+
+.searchForMissingEpisodesLabelContainer {
+ display: flex;
+ margin-top: 2px;
+}
+
+.searchForMissingEpisodesLabel {
+ margin-right: 8px;
+ font-weight: normal;
+}
+
+.searchForMissingEpisodesContainer {
+ composes: container from 'Components/Form/CheckInput.css';
+
+ flex: 0 1 0;
+}
+
+.searchForMissingEpisodesInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
+
+.modalFooter {
+ composes: modalFooter from 'Components/Modal/ModalFooter.css';
+}
+
+.addButton {
+ @add-mixin truncate;
+ composes: button from 'Components/Link/SpinnerButton.css';
+}
+
+.hideLanguageProfile {
+ composes: group from 'Components/Form/FormGroup.css';
+
+ display: none;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalFooter {
+ display: block;
+ text-align: center;
+ }
+
+ .addButton {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js
new file mode 100644
index 000000000..1585ee492
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js
@@ -0,0 +1,265 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import CheckInput from 'Components/Form/CheckInput';
+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 Popover from 'Components/Tooltip/Popover';
+import SeriesPoster from 'Series/SeriesPoster';
+import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
+import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
+import styles from './AddNewSeriesModalContent.css';
+
+class AddNewSeriesModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ searchForMissingEpisodes: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onSearchForMissingEpisodesChange = ({ value }) => {
+ this.setState({ searchForMissingEpisodes: value });
+ }
+
+ onQualityProfileIdChange = ({ value }) => {
+ this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
+ }
+
+ onLanguageProfileIdChange = ({ value }) => {
+ this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) });
+ }
+
+ onAddSeriesPress = () => {
+ this.props.onAddSeriesPress(this.state.searchForMissingEpisodes);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ year,
+ overview,
+ images,
+ isAdding,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder,
+ tags,
+ showLanguageProfile,
+ isSmallScreen,
+ onModalClose,
+ onInputChange
+ } = this.props;
+
+ return (
+
+
+ {title}
+
+ {
+ !title.contains(year) && !!year &&
+ ({year})
+ }
+
+
+
+
+ {
+ !isSmallScreen &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+ Start search for missing episodes
+
+
+
+
+
+
+ Add {title}
+
+
+
+ );
+ }
+}
+
+AddNewSeriesModalContent.propTypes = {
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ overview: PropTypes.string,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isAdding: PropTypes.bool.isRequired,
+ addError: PropTypes.object,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ languageProfileId: PropTypes.object,
+ seriesType: PropTypes.object.isRequired,
+ seasonFolder: PropTypes.object.isRequired,
+ tags: PropTypes.object.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onAddSeriesPress: PropTypes.func.isRequired
+};
+
+export default AddNewSeriesModalContent;
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js
new file mode 100644
index 000000000..dc351933e
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js
@@ -0,0 +1,108 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import selectSettings from 'Store/Selectors/selectSettings';
+import AddNewSeriesModalContent from './AddNewSeriesModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addSeries,
+ (state) => state.settings.languageProfiles,
+ createDimensionsSelector(),
+ (addSeriesState, languageProfiles, dimensions) => {
+ const {
+ isAdding,
+ addError,
+ defaults
+ } = addSeriesState;
+
+ const {
+ settings,
+ validationErrors,
+ validationWarnings
+ } = selectSettings(defaults, {}, addError);
+
+ return {
+ isAdding,
+ addError,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ isSmallScreen: dimensions.isSmallScreen,
+ validationErrors,
+ validationWarnings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setAddSeriesDefault,
+ addSeries
+};
+
+class AddNewSeriesModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setAddSeriesDefault({ [name]: value });
+ }
+
+ onAddSeriesPress = (searchForMissingEpisodes) => {
+ const {
+ tvdbId,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder,
+ tags
+ } = this.props;
+
+ this.props.addSeries({
+ tvdbId,
+ rootFolderPath: rootFolderPath.value,
+ monitor: monitor.value,
+ qualityProfileId: qualityProfileId.value,
+ languageProfileId: languageProfileId.value,
+ seriesType: seriesType.value,
+ seasonFolder: seasonFolder.value,
+ tags: tags.value,
+ searchForMissingEpisodes
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNewSeriesModalContentConnector.propTypes = {
+ tvdbId: PropTypes.number.isRequired,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ languageProfileId: PropTypes.object,
+ seriesType: PropTypes.object.isRequired,
+ seasonFolder: PropTypes.object.isRequired,
+ tags: PropTypes.object.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ setAddSeriesDefault: PropTypes.func.isRequired,
+ addSeries: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css
new file mode 100644
index 000000000..38ccffb4d
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css
@@ -0,0 +1,40 @@
+.searchResult {
+ display: flex;
+ margin: 20px 0;
+ padding: 20px;
+ width: 100%;
+ background-color: $white;
+ color: inherit;
+ transition: background 500ms;
+
+ &:hover {
+ background-color: #eaf2ff;
+ color: inherit;
+ text-decoration: none;
+ }
+}
+
+.poster {
+ flex: 0 0 170px;
+ margin-right: 20px;
+ height: 250px;
+}
+
+.title {
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.year {
+ margin-left: 10px;
+ color: $disabledColor;
+}
+
+.alreadyExistsIcon {
+ margin-left: 10px;
+ color: #37bc9b;
+}
+
+.overview {
+ margin-top: 20px;
+}
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js
new file mode 100644
index 000000000..57f34dff4
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js
@@ -0,0 +1,177 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import SeriesPoster from 'Series/SeriesPoster';
+import AddNewSeriesModal from './AddNewSeriesModal';
+import styles from './AddNewSeriesSearchResult.css';
+
+class AddNewSeriesSearchResult extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isNewAddSeriesModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
+ this.onAddSerisModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isNewAddSeriesModalOpen: true });
+ }
+
+ onAddSerisModalClose = () => {
+ this.setState({ isNewAddSeriesModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ tvdbId,
+ title,
+ titleSlug,
+ year,
+ network,
+ status,
+ overview,
+ statistics,
+ ratings,
+ images,
+ isExistingSeries,
+ isSmallScreen
+ } = this.props;
+
+ const seasonCount = statistics.seasonCount;
+
+ const {
+ isNewAddSeriesModalOpen
+ } = this.state;
+
+ const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress };
+ let seasons = '1 Season';
+
+ if (seasonCount > 1) {
+ seasons = `${seasonCount} Seasons`;
+ }
+
+ return (
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+
+ {title}
+
+ {
+ !title.contains(year) && !!year &&
+ ({year})
+ }
+
+ {
+ isExistingSeries &&
+
+ }
+
+
+
+
+
+
+
+ {
+ !!network &&
+
+ {network}
+
+ }
+
+ {
+ !!seasonCount &&
+
+ {seasons}
+
+ }
+
+ {
+ status === 'ended' &&
+
+ Ended
+
+ }
+
+
+
+ {overview}
+
+
+
+
+
+
+ );
+ }
+}
+
+AddNewSeriesSearchResult.propTypes = {
+ tvdbId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ network: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ ratings: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isExistingSeries: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired
+};
+
+export default AddNewSeriesSearchResult;
diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js
new file mode 100644
index 000000000..5ba942270
--- /dev/null
+++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingSeriesSelector(),
+ createDimensionsSelector(),
+ (isExistingSeries, dimensions) => {
+ return {
+ isExistingSeries,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(AddNewSeriesSearchResult);
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js
new file mode 100644
index 000000000..0f0e2ce1f
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import ImportSeriesTableConnector from './ImportSeriesTableConnector';
+import ImportSeriesFooterConnector from './ImportSeriesFooterConnector';
+
+class ImportSeries extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ contentBody: null,
+ scrollTop: 0
+ };
+ }
+
+ //
+ // Control
+
+ setContentBodyRef = (ref) => {
+ this.setState({ contentBody: ref });
+ }
+
+ //
+ // Listeners
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState, { parseIds: false });
+ }
+
+ onSelectAllChange = ({ value }) => {
+ // Only select non-dupes
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onRemoveSelectedStateItem = (id) => {
+ this.setState((state) => {
+ const selectedState = Object.assign({}, state.selectedState);
+ delete selectedState[id];
+
+ return {
+ ...state,
+ selectedState
+ };
+ });
+ }
+
+ onInputChange = ({ name, value }) => {
+ this.props.onInputChange(this.getSelectedIds(), name, value);
+ }
+
+ onImportPress = () => {
+ this.props.onImportPress(this.getSelectedIds());
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.setState({ scrollTop });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ rootFolderId,
+ path,
+ rootFoldersFetching,
+ rootFoldersPopulated,
+ rootFoldersError,
+ unmappedFolders,
+ showLanguageProfile
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ contentBody
+ } = this.state;
+
+ return (
+
+
+ {
+ rootFoldersFetching && !rootFoldersPopulated &&
+
+ }
+
+ {
+ !rootFoldersFetching && !!rootFoldersError &&
+ Unable to load root folders
+ }
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length &&
+
+ All series in {path} have been imported
+
+ }
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody &&
+
+ }
+
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length &&
+
+ }
+
+ );
+ }
+}
+
+ImportSeries.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ path: PropTypes.string,
+ rootFoldersFetching: PropTypes.bool.isRequired,
+ rootFoldersPopulated: PropTypes.bool.isRequired,
+ rootFoldersError: PropTypes.object,
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object),
+ items: PropTypes.arrayOf(PropTypes.object),
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired
+};
+
+ImportSeries.defaultProps = {
+ unmappedFolders: []
+};
+
+export default ImportSeries;
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js
new file mode 100644
index 000000000..0ab206ef9
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js
@@ -0,0 +1,169 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setImportSeriesValue, importSeries, clearImportSeries } from 'Store/Actions/importSeriesActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
+import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
+import ImportSeries from './ImportSeries';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ (state) => state.rootFolders,
+ (state) => state.addSeries,
+ (state) => state.importSeries,
+ (state) => state.settings.qualityProfiles,
+ (state) => state.settings.languageProfiles,
+ (
+ match,
+ rootFolders,
+ addSeries,
+ importSeriesState,
+ qualityProfiles,
+ languageProfiles
+ ) => {
+ const {
+ isFetching: rootFoldersFetching,
+ isPopulated: rootFoldersPopulated,
+ error: rootFoldersError,
+ items
+ } = rootFolders;
+
+ const rootFolderId = parseInt(match.params.rootFolderId);
+
+ const result = {
+ rootFolderId,
+ rootFoldersFetching,
+ rootFoldersPopulated,
+ rootFoldersError,
+ qualityProfiles: qualityProfiles.items,
+ languageProfiles: languageProfiles.items,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ defaultQualityProfileId: addSeries.defaults.qualityProfileId,
+ defaultLanguageProfileId: addSeries.defaults.languageProfileId
+ };
+
+ if (items.length) {
+ const rootFolder = _.find(items, { id: rootFolderId });
+
+ return {
+ ...result,
+ ...rootFolder,
+ items: importSeriesState.items
+ };
+ }
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetImportSeriesValue: setImportSeriesValue,
+ dispatchImportSeries: importSeries,
+ dispatchClearImportSeries: clearImportSeries,
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchSetAddSeriesDefault: setAddSeriesDefault
+};
+
+class ImportSeriesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ qualityProfiles,
+ languageProfiles,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ dispatchFetchRootFolders,
+ dispatchSetAddSeriesDefault
+ } = this.props;
+
+ if (!this.props.rootFoldersPopulated) {
+ dispatchFetchRootFolders();
+ }
+
+ let setDefaults = false;
+ const setDefaultPayload = {};
+
+ if (
+ !defaultQualityProfileId ||
+ !qualityProfiles.some((p) => p.id === defaultQualityProfileId)
+ ) {
+ setDefaults = true;
+ setDefaultPayload.qualityProfileId = qualityProfiles[0].id;
+ }
+
+ if (
+ !defaultLanguageProfileId ||
+ !languageProfiles.some((p) => p.id === defaultLanguageProfileId)
+ ) {
+ setDefaults = true;
+ setDefaultPayload.languageProfileId = languageProfiles[0].id;
+ }
+
+ if (setDefaults) {
+ dispatchSetAddSeriesDefault(setDefaultPayload);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearImportSeries();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (ids, name, value) => {
+ this.props.dispatchSetAddSeriesDefault({ [name]: value });
+
+ ids.forEach((id) => {
+ this.props.dispatchSetImportSeriesValue({
+ id,
+ [name]: value
+ });
+ });
+ }
+
+ onImportPress = (ids) => {
+ this.props.dispatchImportSeries({ ids });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+const routeMatchShape = createRouteMatchShape({
+ rootFolderId: PropTypes.string.isRequired
+});
+
+ImportSeriesConnector.propTypes = {
+ match: routeMatchShape.isRequired,
+ rootFoldersPopulated: PropTypes.bool.isRequired,
+ qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ languageProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ defaultQualityProfileId: PropTypes.number.isRequired,
+ defaultLanguageProfileId: PropTypes.number.isRequired,
+ dispatchSetImportSeriesValue: PropTypes.func.isRequired,
+ dispatchImportSeries: PropTypes.func.isRequired,
+ dispatchClearImportSeries: PropTypes.func.isRequired,
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchSetAddSeriesDefault: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css
new file mode 100644
index 000000000..0a61ca509
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css
@@ -0,0 +1,33 @@
+.inputContainer {
+ margin-right: 20px;
+ min-width: 150px;
+}
+
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.importButtonContainer {
+ display: flex;
+ align-items: center;
+}
+
+.importButton {
+ composes: button from 'Components/Link/SpinnerButton.css';
+
+ height: 35px;
+}
+
+.loadingButton {
+ composes: importButton;
+
+ margin-left: 10px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin: 0 10px 0 12px;
+ text-align: left;
+}
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js
new file mode 100644
index 000000000..9d28dd299
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js
@@ -0,0 +1,291 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import CheckInput from 'Components/Form/CheckInput';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import styles from './ImportSeriesFooter.css';
+
+const MIXED = 'mixed';
+
+class ImportSeriesFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultSeasonFolder,
+ defaultSeriesType
+ } = props;
+
+ this.state = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ languageProfileId: defaultLanguageProfileId,
+ seriesType: defaultSeriesType,
+ seasonFolder: defaultSeasonFolder
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultSeriesType,
+ defaultSeasonFolder,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isLanguageProfileIdMixed,
+ isSeriesTypeMixed,
+ isSeasonFolderMixed
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder
+ } = this.state;
+
+ const newState = {};
+
+ if (isMonitorMixed && monitor !== MIXED) {
+ newState.monitor = MIXED;
+ } else if (!isMonitorMixed && monitor !== defaultMonitor) {
+ newState.monitor = defaultMonitor;
+ }
+
+ if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
+ newState.qualityProfileId = MIXED;
+ } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
+ newState.qualityProfileId = defaultQualityProfileId;
+ }
+
+ if (isLanguageProfileIdMixed && languageProfileId !== MIXED) {
+ newState.languageProfileId = MIXED;
+ } else if (!isLanguageProfileIdMixed && languageProfileId !== defaultLanguageProfileId) {
+ newState.languageProfileId = defaultLanguageProfileId;
+ }
+
+ if (isSeriesTypeMixed && seriesType !== MIXED) {
+ newState.seriesType = MIXED;
+ } else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
+ newState.seriesType = defaultSeriesType;
+ }
+
+ if (isSeasonFolderMixed && seasonFolder != null) {
+ newState.seasonFolder = null;
+ } else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
+ newState.seasonFolder = defaultSeasonFolder;
+ }
+
+ if (!_.isEmpty(newState)) {
+ this.setState(newState);
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ this.props.onInputChange({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedCount,
+ isImporting,
+ isLookingUpSeries,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isLanguageProfileIdMixed,
+ isSeriesTypeMixed,
+ hasUnsearchedItems,
+ showLanguageProfile,
+ onImportPress,
+ onLookupPress,
+ onCancelLookupPress
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder
+ } = this.state;
+
+ return (
+
+
+
+
+
+ Quality Profile
+
+
+
+
+
+ {
+ showLanguageProfile &&
+
+
+ Language Profile
+
+
+
+
+ }
+
+
+
+
+
+ Season Folder
+
+
+
+
+
+
+
+
+
+
+
+
+ Import {selectedCount} Series
+
+
+ {
+ isLookingUpSeries &&
+
+ Cancel Processing
+
+ }
+
+ {
+ hasUnsearchedItems &&
+
+ Start Processing
+
+ }
+
+ {
+ isLookingUpSeries &&
+
+ }
+
+ {
+ isLookingUpSeries &&
+ 'Processing Folders'
+ }
+
+
+
+ );
+ }
+}
+
+ImportSeriesFooter.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ isImporting: PropTypes.bool.isRequired,
+ isLookingUpSeries: PropTypes.bool.isRequired,
+ defaultMonitor: PropTypes.string.isRequired,
+ defaultQualityProfileId: PropTypes.number,
+ defaultLanguageProfileId: PropTypes.number,
+ defaultSeriesType: PropTypes.string.isRequired,
+ defaultSeasonFolder: PropTypes.bool.isRequired,
+ isMonitorMixed: PropTypes.bool.isRequired,
+ isQualityProfileIdMixed: PropTypes.bool.isRequired,
+ isLanguageProfileIdMixed: PropTypes.bool.isRequired,
+ isSeriesTypeMixed: PropTypes.bool.isRequired,
+ isSeasonFolderMixed: PropTypes.bool.isRequired,
+ hasUnsearchedItems: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired,
+ onLookupPress: PropTypes.func.isRequired,
+ onCancelLookupPress: PropTypes.func.isRequired
+};
+
+export default ImportSeriesFooter;
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js
new file mode 100644
index 000000000..4983f5663
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js
@@ -0,0 +1,65 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { lookupUnsearchedSeries, cancelLookupSeries } from 'Store/Actions/importSeriesActions';
+import ImportSeriesFooter from './ImportSeriesFooter';
+
+function isMixed(items, selectedIds, defaultValue, key) {
+ return _.some(items, (series) => {
+ return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue;
+ });
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addSeries,
+ (state) => state.importSeries,
+ (state, { selectedIds }) => selectedIds,
+ (addSeries, importSeries, selectedIds) => {
+ const {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ languageProfileId: defaultLanguageProfileId,
+ seriesType: defaultSeriesType,
+ seasonFolder: defaultSeasonFolder
+ } = addSeries.defaults;
+
+ const {
+ isLookingUpSeries,
+ isImporting,
+ items
+ } = importSeries;
+
+ const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
+ const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
+ const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId');
+ const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType');
+ const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder');
+ const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated);
+
+ return {
+ selectedCount: selectedIds.length,
+ isLookingUpSeries,
+ isImporting,
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultSeriesType,
+ defaultSeasonFolder,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isLanguageProfileIdMixed,
+ isSeriesTypeMixed,
+ isSeasonFolderMixed,
+ hasUnsearchedItems
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onLookupPress: lookupUnsearchedSeries,
+ onCancelLookupPress: cancelLookupSeries
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter);
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css
new file mode 100644
index 000000000..36a57ea73
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css
@@ -0,0 +1,45 @@
+.folder {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 200px;
+}
+
+.monitor {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 200px;
+ min-width: 185px;
+}
+
+.qualityProfile,
+.languageProfile {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.seriesType {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 200px;
+ min-width: 120px;
+}
+
+.seasonFolder {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 150px;
+ min-width: 120px;
+}
+
+.series {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
+
+.detailsIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js
new file mode 100644
index 000000000..fe60a4d5d
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
+import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
+import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
+import styles from './ImportSeriesHeader.css';
+
+function ImportSeriesHeader(props) {
+ const {
+ showLanguageProfile,
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ return (
+
+
+
+
+ Folder
+
+
+
+ Monitor
+
+
+ }
+ title="Monitoring Options"
+ body={ }
+ position={tooltipPositions.RIGHT}
+ />
+
+
+
+ Quality Profile
+
+
+ {
+ showLanguageProfile &&
+
+ Language Profile
+
+ }
+
+
+ Series Type
+
+
+ }
+ title="Series Type"
+ body={ }
+ position={tooltipPositions.RIGHT}
+ />
+
+
+
+ Season Folder
+
+
+
+ Series
+
+
+ );
+}
+
+ImportSeriesHeader.propTypes = {
+ showLanguageProfile: PropTypes.bool.isRequired,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default ImportSeriesHeader;
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css
new file mode 100644
index 000000000..10329ea1c
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css
@@ -0,0 +1,52 @@
+.selectInput {
+ composes: input from 'Components/Form/CheckInput.css';
+}
+
+.folder {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 200px;
+ line-height: 36px;
+}
+
+.monitor {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 200px;
+ min-width: 185px;
+}
+
+.qualityProfile,
+.languageProfile {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.seriesType {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 200px;
+ min-width: 120px;
+}
+
+.seasonFolder {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 150px;
+ min-width: 120px;
+}
+
+.series {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
+
+.hideLanguageProfile {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: none;
+}
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js
new file mode 100644
index 000000000..a9dad2af0
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js
@@ -0,0 +1,120 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import VirtualTableRow from 'Components/Table/VirtualTableRow';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
+import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector';
+import styles from './ImportSeriesRow.css';
+
+function ImportSeriesRow(props) {
+ const {
+ style,
+ id,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seasonFolder,
+ seriesType,
+ selectedSeries,
+ isExistingSeries,
+ showLanguageProfile,
+ isSelected,
+ onSelectedChange,
+ onInputChange
+ } = props;
+
+ return (
+
+
+
+
+ {id}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+ImportSeriesRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ languageProfileId: PropTypes.number.isRequired,
+ seriesType: PropTypes.string.isRequired,
+ seasonFolder: PropTypes.bool.isRequired,
+ selectedSeries: PropTypes.object,
+ isExistingSeries: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+ImportSeriesRow.defaultsProps = {
+ items: []
+};
+
+export default ImportSeriesRow;
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js
new file mode 100644
index 000000000..1eb8f01ff
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js
@@ -0,0 +1,89 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import ImportSeriesRow from './ImportSeriesRow';
+
+function createImportSeriesItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.importSeries.items,
+ (id, items) => {
+ return _.find(items, { id }) || {};
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createImportSeriesItemSelector(),
+ createAllSeriesSelector(),
+ (item, series) => {
+ const selectedSeries = item && item.selectedSeries;
+ const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
+
+ return {
+ ...item,
+ isExistingSeries
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setImportSeriesValue
+};
+
+class ImportSeriesRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setImportSeriesValue({
+ id: this.props.id,
+ [name]: value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ // Don't show the row until we have the information we require for it.
+
+ const {
+ items,
+ monitor,
+ seriesType,
+ seasonFolder
+ } = this.props;
+
+ if (!items || !monitor || !seriesType || !seasonFolder == null) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+ImportSeriesRowConnector.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string,
+ seriesType: PropTypes.string,
+ seasonFolder: PropTypes.bool,
+ items: PropTypes.arrayOf(PropTypes.object),
+ setImportSeriesValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css
new file mode 100644
index 000000000..efc6dccb3
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css
@@ -0,0 +1,3 @@
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+}
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js
new file mode 100644
index 000000000..1f4c6e251
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js
@@ -0,0 +1,197 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import VirtualTable from 'Components/Table/VirtualTable';
+import ImportSeriesHeader from './ImportSeriesHeader';
+import ImportSeriesRowConnector from './ImportSeriesRowConnector';
+
+class ImportSeriesTable extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ unmappedFolders,
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultSeriesType,
+ defaultSeasonFolder,
+ onSeriesLookup,
+ onSetImportSeriesValue
+ } = this.props;
+
+ const values = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ languageProfileId: defaultLanguageProfileId,
+ seriesType: defaultSeriesType,
+ seasonFolder: defaultSeasonFolder
+ };
+
+ unmappedFolders.forEach((unmappedFolder) => {
+ const id = unmappedFolder.name;
+
+ onSeriesLookup(id, unmappedFolder.path);
+
+ onSetImportSeriesValue({
+ id,
+ ...values
+ });
+ });
+ }
+
+ // This isn't great, but it's the most reliable way to ensure the items
+ // are checked off even if they aren't actually visible since the cells
+ // are virtualized.
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ selectedState,
+ onSelectedChange,
+ onRemoveSelectedStateItem
+ } = this.props;
+
+ prevProps.items.forEach((prevItem) => {
+ const {
+ id
+ } = prevItem;
+
+ const item = _.find(items, { id });
+
+ if (!item) {
+ onRemoveSelectedStateItem(id);
+ return;
+ }
+
+ const selectedSeries = item.selectedSeries;
+ const isSelected = selectedState[id];
+
+ const isExistingSeries = !!selectedSeries &&
+ _.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId });
+
+ // Props doesn't have a selected series or
+ // the selected series is an existing series.
+ if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // State is selected, but a series isn't selected or
+ // the selected series is an existing series.
+ if (isSelected && (!selectedSeries || isExistingSeries)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // A series is being selected that wasn't previously selected.
+ if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
+ onSelectedChange({ id, value: true });
+
+ return;
+ }
+ });
+ }
+
+ //
+ // Control
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ rootFolderId,
+ items,
+ selectedState,
+ showLanguageProfile,
+ onSelectedChange
+ } = this.props;
+
+ const item = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ allSelected,
+ allUnselected,
+ isSmallScreen,
+ contentBody,
+ showLanguageProfile,
+ scrollTop,
+ selectedState,
+ onSelectAllChange,
+ onScroll
+ } = this.props;
+
+ if (!items.length) {
+ return null;
+ }
+
+ return (
+
+ }
+ selectedState={selectedState}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+ImportSeriesTable.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object),
+ defaultMonitor: PropTypes.string.isRequired,
+ defaultQualityProfileId: PropTypes.number,
+ defaultLanguageProfileId: PropTypes.number,
+ defaultSeriesType: PropTypes.string.isRequired,
+ defaultSeasonFolder: PropTypes.bool.isRequired,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ selectedState: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ allSeries: PropTypes.arrayOf(PropTypes.object),
+ contentBody: PropTypes.object.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired,
+ onSelectedChange: PropTypes.func.isRequired,
+ onRemoveSelectedStateItem: PropTypes.func.isRequired,
+ onSeriesLookup: PropTypes.func.isRequired,
+ onSetImportSeriesValue: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ImportSeriesTable;
diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js
new file mode 100644
index 000000000..a09d5fa80
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js
@@ -0,0 +1,44 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import ImportSeriesTable from './ImportSeriesTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addSeries,
+ (state) => state.importSeries,
+ (state) => state.app.dimensions,
+ createAllSeriesSelector(),
+ (addSeries, importSeries, dimensions, allSeries) => {
+ return {
+ defaultMonitor: addSeries.defaults.monitor,
+ defaultQualityProfileId: addSeries.defaults.qualityProfileId,
+ defaultLanguageProfileId: addSeries.defaults.languageProfileId,
+ defaultSeriesType: addSeries.defaults.seriesType,
+ defaultSeasonFolder: addSeries.defaults.seasonFolder,
+ items: importSeries.items,
+ isSmallScreen: dimensions.isSmallScreen,
+ allSeries
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSeriesLookup(name, path) {
+ dispatch(queueLookupSeries({
+ name,
+ path,
+ term: name
+ }));
+ },
+
+ onSetImportSeriesValue(values) {
+ dispatch(setImportSeriesValue(values));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable);
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css
new file mode 100644
index 000000000..a862c117c
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css
@@ -0,0 +1,8 @@
+.series {
+ padding: 10px 20px;
+ width: 100%;
+
+ &:hover {
+ background-color: $menuItemHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js
new file mode 100644
index 000000000..d82cdc924
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import ImportSeriesTitle from './ImportSeriesTitle';
+import styles from './ImportSeriesSearchResult.css';
+
+class ImportSeriesSearchResult extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.tvdbId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ year,
+ network,
+ isExistingSeries
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+ImportSeriesSearchResult.propTypes = {
+ tvdbId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ network: PropTypes.string,
+ isExistingSeries: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default ImportSeriesSearchResult;
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js
new file mode 100644
index 000000000..81bb3059b
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
+import ImportSeriesSearchResult from './ImportSeriesSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingSeriesSelector(),
+ (isExistingSeries) => {
+ return {
+ isExistingSeries
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ImportSeriesSearchResult);
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css
new file mode 100644
index 000000000..32ff0489b
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css
@@ -0,0 +1,81 @@
+.tether {
+ z-index: 2000;
+}
+
+.button {
+ 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;
+}
+
+.loading {
+ display: inline-block;
+}
+
+.warningIcon {
+ margin-right: 8px;
+}
+
+.existing {
+ margin-left: 5px;
+}
+
+.dropdownArrowContainer {
+ flex: 1 0 auto;
+ margin-left: 5px;
+ text-align: right;
+}
+
+.contentContainer {
+ margin-top: 4px;
+ padding: 0 8px;
+ width: 400px;
+}
+
+.content {
+ padding: 4px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
+
+.searchContainer {
+ display: flex;
+}
+
+.searchIconContainer {
+ width: 58px;
+ border: 1px solid $inputBorderColor;
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: #edf1f2;
+ text-align: center;
+ line-height: 33px;
+}
+
+.searchInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ border-radius: 0;
+}
+
+.results {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+
+ overflow-x: hidden;
+ overflow-y: scroll;
+ max-height: 165px;
+}
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js
new file mode 100644
index 000000000..8e21dcb05
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js
@@ -0,0 +1,280 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputButton from 'Components/Form/FormInputButton';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import TextInput from 'Components/Form/TextInput';
+import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector';
+import ImportSeriesTitle from './ImportSeriesTitle';
+import styles from './ImportSeriesSelectSeries.css';
+
+const tetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ],
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+};
+
+class ImportSeriesSelectSeries extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._seriesLookupTimeout = null;
+
+ this.state = {
+ term: props.id,
+ isOpen: false
+ };
+ }
+
+ //
+ // Control
+
+ _setButtonRef = (ref) => {
+ this._buttonRef = ref;
+ }
+
+ _setContentRef = (ref) => {
+ this._contentRef = ref;
+ }
+
+ _addListener() {
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const button = ReactDOM.findDOMNode(this._buttonRef);
+ const content = ReactDOM.findDOMNode(this._contentRef);
+
+ if (!button) {
+ return;
+ }
+
+ if (!button.contains(event.target) && content && !content.contains(event.target) && this.state.isOpen) {
+ this.setState({ isOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onPress = () => {
+ if (this.state.isOpen) {
+ this._removeListener();
+ } else {
+ this._addListener();
+ }
+
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onSearchInputChange = ({ value }) => {
+ if (this._seriesLookupTimeout) {
+ clearTimeout(this._seriesLookupTimeout);
+ }
+
+ this.setState({ term: value }, () => {
+ this._seriesLookupTimeout = setTimeout(() => {
+ this.props.onSearchInputChange(value);
+ }, 200);
+ });
+ }
+
+ onRefreshPress = () => {
+ this.props.onSearchInputChange(this.state.term);
+ }
+
+ onSeriesSelect = (tvdbId) => {
+ this.setState({ isOpen: false });
+
+ this.props.onSeriesSelect(tvdbId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedSeries,
+ isExistingSeries,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isQueued,
+ isLookingUpSeries
+ } = this.props;
+
+ const errorMessage = error &&
+ error.responseJSON &&
+ error.responseJSON.message;
+
+ return (
+
+
+ {
+ isLookingUpSeries && isQueued && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && selectedSeries && isExistingSeries &&
+
+ }
+
+ {
+ isPopulated && selectedSeries &&
+
+ }
+
+ {
+ isPopulated && !selectedSeries &&
+
+
+
+ No match found!
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+
+
+ Search failed, please try again later.
+
+ }
+
+
+
+
+
+
+ {
+ this.state.isOpen &&
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ );
+ }
+}
+
+ImportSeriesSelectSeries.propTypes = {
+ id: PropTypes.string.isRequired,
+ selectedSeries: PropTypes.object,
+ isExistingSeries: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isQueued: PropTypes.bool.isRequired,
+ isLookingUpSeries: PropTypes.bool.isRequired,
+ onSearchInputChange: PropTypes.func.isRequired,
+ onSeriesSelect: PropTypes.func.isRequired
+};
+
+ImportSeriesSelectSeries.defaultProps = {
+ isFetching: true,
+ isPopulated: false,
+ items: [],
+ isQueued: true
+};
+
+export default ImportSeriesSelectSeries;
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js
new file mode 100644
index 000000000..a460d6caf
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js
@@ -0,0 +1,76 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
+import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
+import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.importSeries.isLookingUpSeries,
+ createImportSeriesItemSelector(),
+ (isLookingUpSeries, item) => {
+ return {
+ isLookingUpSeries,
+ ...item
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ queueLookupSeries,
+ setImportSeriesValue
+};
+
+class ImportSeriesSelectSeriesConnector extends Component {
+
+ //
+ // Listeners
+
+ onSearchInputChange = (term) => {
+ this.props.queueLookupSeries({
+ name: this.props.id,
+ term,
+ topOfQueue: true
+ });
+ }
+
+ onSeriesSelect = (tvdbId) => {
+ const {
+ id,
+ items
+ } = this.props;
+
+ this.props.setImportSeriesValue({
+ id,
+ selectedSeries: _.find(items, { tvdbId })
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportSeriesSelectSeriesConnector.propTypes = {
+ id: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ selectedSeries: PropTypes.object,
+ isSelected: PropTypes.bool,
+ queueLookupSeries: PropTypes.func.isRequired,
+ setImportSeriesValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector);
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css
new file mode 100644
index 000000000..222623179
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css
@@ -0,0 +1,20 @@
+.titleContainer {
+ display: flex;
+ align-items: center;
+ flex: 0 1 auto;
+ overflow: hidden;
+}
+
+.title {
+ @add-mixin truncate;
+}
+
+.year {
+ margin-right: 5px;
+ margin-left: 5px;
+ color: $disabledColor;
+}
+
+.existing {
+ margin-left: 5px;
+}
diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js
new file mode 100644
index 000000000..5dfabe6f4
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import styles from './ImportSeriesTitle.css';
+
+function ImportSeriesTitle(props) {
+ const {
+ title,
+ year,
+ network,
+ isExistingSeries
+ } = props;
+
+ return (
+
+
+ {title}
+
+
+ {
+ !title.contains(year) &&
+ year > 0 &&
+
+ ({year})
+
+ }
+
+ {
+ !!network &&
+
{network}
+ }
+
+ {
+ isExistingSeries &&
+
+ Existing
+
+ }
+
+ );
+}
+
+ImportSeriesTitle.propTypes = {
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ network: PropTypes.string,
+ isExistingSeries: PropTypes.bool.isRequired
+};
+
+export default ImportSeriesTitle;
diff --git a/frontend/src/AddSeries/ImportSeries/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/ImportSeries.js
new file mode 100644
index 000000000..15362a7d0
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/ImportSeries.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
+import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector';
+
+class ImportSeries extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default ImportSeries;
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css
new file mode 100644
index 000000000..d9c5ccb01
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css
@@ -0,0 +1,18 @@
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ display: block;
+}
+
+.freeSpace,
+.unmappedFolders {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 45px;
+}
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js
new file mode 100644
index 000000000..89ea713a0
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './ImportSeriesRootFolderRow.css';
+
+function ImportSeriesRootFolderRow(props) {
+ const {
+ id,
+ path,
+ freeSpace,
+ unmappedFolders,
+ onDeletePress
+ } = props;
+
+ const unmappedFoldersCount = unmappedFolders.length || '-';
+
+ return (
+
+
+
+ {path}
+
+
+
+
+ {formatBytes(freeSpace) || '-'}
+
+
+
+ {unmappedFoldersCount}
+
+
+
+
+
+
+ );
+}
+
+ImportSeriesRootFolderRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number.isRequired,
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired
+};
+
+ImportSeriesRootFolderRow.defaultProps = {
+ freeSpace: 0,
+ unmappedFolders: []
+};
+
+export default ImportSeriesRootFolderRow;
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js
new file mode 100644
index 000000000..f0fb03921
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteRootFolder } from 'Store/Actions/rootFolderActions';
+import ImportSeriesRootFolderRow from './ImportSeriesRootFolderRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ () => {
+ return {
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteRootFolder
+};
+
+class ImportSeriesRootFolderRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = () => {
+ this.props.deleteRootFolder({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportSeriesRootFolderRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ deleteRootFolder: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRootFolderRowConnector);
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css
new file mode 100644
index 000000000..030da96fb
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css
@@ -0,0 +1,32 @@
+.header {
+ margin-bottom: 40px;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.tips {
+ font-size: 20px;
+}
+
+.tip {
+ font-size: $defaultFontSize;
+}
+
+.code {
+ font-size: 12px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.recentFolders {
+ margin-top: 40px;
+}
+
+.startImport {
+ margin-top: 40px;
+ text-align: center;
+}
+
+.importButtonIcon {
+ margin-right: 8px;
+}
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js
new file mode 100644
index 000000000..13b9d5f6d
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js
@@ -0,0 +1,188 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import ImportSeriesRootFolderRowConnector from './ImportSeriesRootFolderRowConnector';
+import styles from './ImportSeriesSelectFolder.css';
+
+const rootFolderColumns = [
+ {
+ name: 'path',
+ label: 'Path',
+ isVisible: true
+ },
+ {
+ name: 'freeSpace',
+ label: 'Free Space',
+ isVisible: true
+ },
+ {
+ name: 'unmappedFolders',
+ label: 'Unmapped Folders',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+class ImportSeriesSelectFolder extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNewRootFolderModalOpen: false
+ };
+ }
+
+ //
+ // Lifecycle
+
+ onAddNewRootFolderPress = () => {
+ this.setState({ isAddNewRootFolderModalOpen: true });
+ }
+
+ onNewRootFolderSelect = ({ value }) => {
+ this.props.onNewRootFolderSelect(value);
+ }
+
+ onAddRootFolderModalClose = () => {
+ this.setState({ isAddNewRootFolderModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isWindows,
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = this.props;
+
+ return (
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load root folders
+ }
+
+ {
+ !error && isPopulated &&
+
+
+ Import series you already have
+
+
+
+ Some tips to ensure the import goes smoothly:
+
+
+ Make sure your files include the quality in the name. eg. episode.s02e15.bluray.mkv
+
+
+ Point Sonarr to the folder containing all of your tv shows not a specific one. eg. "{isWindows ? 'C:\\tv shows' : '/tv shows'}" and not "{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}"
+
+
+
+
+ {
+ items.length > 0 ?
+
+
+
+
+ {
+ items.map((rootFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+ Choose another folder
+
+
:
+
+
+
+
+ Start Import
+
+
+ }
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ImportSeriesSelectFolder.propTypes = {
+ isWindows: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNewRootFolderSelect: PropTypes.func.isRequired,
+ onDeleteRootFolderPress: PropTypes.func.isRequired
+};
+
+export default ImportSeriesSelectFolder;
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
new file mode 100644
index 000000000..60729cd6a
--- /dev/null
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { push } from 'react-router-redux';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions';
+import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ createSystemStatusSelector(),
+ (rootFolders, systemStatus) => {
+ return {
+ ...rootFolders,
+ isWindows: systemStatus.isWindows
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRootFolders,
+ addRootFolder,
+ deleteRootFolder,
+ push
+};
+
+class ImportSeriesSelectFolderConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
+
+ if (newRootFolders.length === 1) {
+ this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onNewRootFolderSelect = (path) => {
+ this.props.addRootFolder({ path });
+ }
+
+ onDeleteRootFolderPress = (id) => {
+ this.props.deleteRootFolder({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportSeriesSelectFolderConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchRootFolders: PropTypes.func.isRequired,
+ addRootFolder: PropTypes.func.isRequired,
+ deleteRootFolder: PropTypes.func.isRequired,
+ push: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector);
diff --git a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
new file mode 100644
index 000000000..e889fbb09
--- /dev/null
+++ b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+
+function SeriesMonitoringOptionsPopoverContent() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SeriesMonitoringOptionsPopoverContent;
diff --git a/frontend/src/AddSeries/SeriesTypePopoverContent.js b/frontend/src/AddSeries/SeriesTypePopoverContent.js
new file mode 100644
index 000000000..e57d49a9e
--- /dev/null
+++ b/frontend/src/AddSeries/SeriesTypePopoverContent.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+
+function SeriesTypePopoverContent() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default SeriesTypePopoverContent;
diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js
new file mode 100644
index 000000000..a05ff1fc6
--- /dev/null
+++ b/frontend/src/App/App.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import { Provider } from 'react-redux';
+import { ConnectedRouter } from 'react-router-redux';
+import PageConnector from 'Components/Page/PageConnector';
+import AppRoutes from './AppRoutes';
+
+function App({ store, history }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+App.propTypes = {
+ store: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired
+};
+
+export default App;
diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js
new file mode 100644
index 000000000..4ab75eb22
--- /dev/null
+++ b/frontend/src/App/AppRoutes.js
@@ -0,0 +1,248 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Route, Redirect } from 'react-router-dom';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import NotFound from 'Components/NotFound';
+import Switch from 'Components/Router/Switch';
+import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector';
+import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
+import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
+import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector';
+import SeasonPassConnector from 'SeasonPass/SeasonPassConnector';
+import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
+import CalendarPageConnector from 'Calendar/CalendarPageConnector';
+import HistoryConnector from 'Activity/History/HistoryConnector';
+import QueueConnector from 'Activity/Queue/QueueConnector';
+import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector';
+import MissingConnector from 'Wanted/Missing/MissingConnector';
+import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
+import Settings from 'Settings/Settings';
+import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
+import Profiles from 'Settings/Profiles/Profiles';
+import Quality from 'Settings/Quality/Quality';
+import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
+import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
+import NotificationSettings from 'Settings/Notifications/NotificationSettings';
+import MetadataSettings from 'Settings/Metadata/MetadataSettings';
+import TagSettings from 'Settings/Tags/TagSettings';
+import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
+import UISettingsConnector from 'Settings/UI/UISettingsConnector';
+import Status from 'System/Status/Status';
+import Tasks from 'System/Tasks/Tasks';
+import BackupsConnector from 'System/Backup/BackupsConnector';
+import UpdatesConnector from 'System/Updates/UpdatesConnector';
+import LogsTableConnector from 'System/Events/LogsTableConnector';
+import Logs from 'System/Logs/Logs';
+
+function AppRoutes(props) {
+ const {
+ app
+ } = props;
+
+ return (
+
+ {/*
+ Series
+ */}
+
+
+
+ {
+ window.Sonarr.urlBase &&
+ {
+ return (
+
+ );
+ }}
+ />
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Calendar
+ */}
+
+
+
+ {/*
+ Activity
+ */}
+
+
+
+
+
+
+
+ {/*
+ Wanted
+ */}
+
+
+
+
+
+ {/*
+ Settings
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ System
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not Found
+ */}
+
+
+
+ );
+}
+
+AppRoutes.propTypes = {
+ app: PropTypes.func.isRequired
+};
+
+export default AppRoutes;
diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js
new file mode 100644
index 000000000..abc7f8832
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModal.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
+
+function AppUpdatedModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AppUpdatedModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js
new file mode 100644
index 000000000..a21afbc5a
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import AppUpdatedModal from './AppUpdatedModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(AppUpdatedModal);
diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css
new file mode 100644
index 000000000..37b89c9be
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.css
@@ -0,0 +1,15 @@
+.version {
+ margin: 0 3px;
+ font-weight: bold;
+}
+
+.maintenance {
+ margin-top: 20px;
+}
+
+.changes {
+ margin-top: 20px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+ font-size: 18px;
+}
diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js
new file mode 100644
index 000000000..c08dc6177
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Button from 'Components/Link/Button';
+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 UpdateChanges from 'System/Updates/UpdateChanges';
+import styles from './AppUpdatedModalContent.css';
+
+function AppUpdatedModalContent(props) {
+ const {
+ version,
+ isPopulated,
+ error,
+ items,
+ onSeeChangesPress,
+ onModalClose
+ } = props;
+
+ const update = items[0];
+
+ return (
+
+
+ Sonarr Updated
+
+
+
+
+ Version {version} of Sonarr has been installed, in order to get the latest changes you'll need to reload Sonarr.
+
+
+ {
+ isPopulated && !error && !!update &&
+
+ {
+ !update.changes &&
+
Maintenance release
+ }
+
+ {
+ !!update.changes &&
+
+
+ What's new?
+
+
+
+
+
+
+ }
+
+ }
+
+ {
+ !isPopulated && !error &&
+
+ }
+
+
+
+
+ Recent Changes
+
+
+
+ Reload
+
+
+
+ );
+}
+
+AppUpdatedModalContent.propTypes = {
+ version: PropTypes.string.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSeeChangesPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AppUpdatedModalContent;
diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js
new file mode 100644
index 000000000..b252868ce
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContentConnector.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import AppUpdatedModalContent from './AppUpdatedModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.version,
+ (state) => state.system.updates,
+ (version, updates) => {
+ const {
+ isPopulated,
+ error,
+ items
+ } = updates;
+
+ return {
+ version,
+ isPopulated,
+ error,
+ items
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchUpdates() {
+ dispatch(fetchUpdates());
+ },
+
+ onSeeChangesPress() {
+ window.location = `${window.Sonarr.urlBase}/system/updates`;
+ }
+ };
+}
+
+class AppUpdatedModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchUpdates();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.version !== this.props.version) {
+ this.props.dispatchFetchUpdates();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchUpdates,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+AppUpdatedModalContentConnector.propTypes = {
+ version: PropTypes.string.isRequired,
+ dispatchFetchUpdates: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);
diff --git a/frontend/src/App/ConnectionLostModal.css b/frontend/src/App/ConnectionLostModal.css
new file mode 100644
index 000000000..f0a9d220f
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModal.css
@@ -0,0 +1,3 @@
+.automatic {
+ margin-top: 20px;
+}
diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js
new file mode 100644
index 000000000..9ebed8ed3
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModal.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+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 styles from './ConnectionLostModal.css';
+
+function ConnectionLostModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ Connnection Lost
+
+
+
+
+ Sonarr has lost it's connection to the backend and will need to be reloaded to restore functionality.
+
+
+
+ Sonarr will try to connect automatically, or you can click reload below.
+
+
+
+
+ Reload
+
+
+
+
+ );
+}
+
+ConnectionLostModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js
new file mode 100644
index 000000000..8ab8e3cd0
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import ConnectionLostModal from './ConnectionLostModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);
diff --git a/frontend/src/Calendar/Agenda/Agenda.css b/frontend/src/Calendar/Agenda/Agenda.css
new file mode 100644
index 000000000..0304d9db5
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.css
@@ -0,0 +1,3 @@
+.agenda {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js
new file mode 100644
index 000000000..89472301d
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.js
@@ -0,0 +1,38 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import AgendaEventConnector from './AgendaEventConnector';
+import styles from './Agenda.css';
+
+function Agenda(props) {
+ const {
+ items
+ } = props;
+
+ return (
+
+ {
+ items.map((item, index) => {
+ const momentDate = moment(item.airDateUtc);
+ const showDate = index === 0 ||
+ !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
+
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+Agenda.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Agenda;
diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js
new file mode 100644
index 000000000..b6f238873
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaConnector.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import Agenda from './Agenda';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Agenda);
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css
new file mode 100644
index 000000000..e62cf63c2
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css
@@ -0,0 +1,117 @@
+.event {
+ display: flex;
+ overflow-x: hidden;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ font-size: $defaultFontSize;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.eventWrapper {
+ display: flex;
+ flex: 1 0 1px;
+ overflow-x: hidden;
+ padding-left: 6px;
+ border-left-width: 4px;
+ border-left-style: solid;
+}
+
+.date {
+ flex: 0 0 250px;
+ font-weight: bold;
+}
+
+.time {
+ flex: 0 0 120px;
+ margin-right: 10px;
+ border: none !important;
+}
+
+.seriesTitle,
+.episodeTitle {
+ @add-mixin truncate;
+
+ flex: 0 1 300px;
+ margin-right: 10px;
+}
+
+.episodeTitle {
+ flex: 1 1 1px;
+}
+
+.seasonEpisodeNumber {
+ flex: 0 0 100px;
+}
+
+.episodeSeparator {
+ display: none;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from 'Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
+}
+
+.onAir {
+ composes: onAir from 'Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from 'Calendar/Events/CalendarEvent.css';
+}
+
+.premiere {
+ composes: premiere from 'Calendar/Events/CalendarEvent.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .event {
+ flex-direction: column;
+ }
+
+ .eventWrapper {
+ display: block;
+ flex: 0 0 auto;
+ }
+
+ .date {
+ margin-left: 10px;
+ }
+
+ .date,
+ .time,
+ .seriesTitle {
+ flex: 0 0 100%;
+ }
+
+ .seasonEpisodeNumber {
+ flex: 0 0 auto;
+ }
+
+ .episodeSeparator {
+ display: inline-block;
+ margin: 0 5px;
+ }
+}
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js
new file mode 100644
index 000000000..3d5aa36fb
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.js
@@ -0,0 +1,253 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import { icons, kinds } from 'Helpers/Props';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import episodeEntities from 'Episode/episodeEntities';
+import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
+import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
+import styles from './AgendaEvent.css';
+
+class AgendaEvent extends Component {
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ series,
+ episodeFile,
+ title,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDateUtc,
+ monitored,
+ hasFile,
+ grabbed,
+ queueItem,
+ showDate,
+ showEpisodeInformation,
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ timeFormat,
+ longDateFormat,
+ colorImpairedMode
+ } = this.props;
+
+ const startTime = moment(airDateUtc);
+ const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = series.monitored && monitored;
+ const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored);
+ const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
+ const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
+ const seasonStatistics = season.statistics || {};
+
+ return (
+
+
+
+ {
+ showDate &&
+ startTime.format(longDateFormat)
+ }
+
+
+
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
+
+
+
+ {series.title}
+
+
+ {
+ showEpisodeInformation &&
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ {
+ series.seriesType === 'anime' && absoluteEpisodeNumber &&
+
({absoluteEpisodeNumber})
+ }
+
+
-
+
+ }
+
+
+ {
+ showEpisodeInformation &&
+ title
+ }
+
+
+ {
+ missingAbsoluteNumber &&
+
+ }
+
+ {
+ !!queueItem &&
+
+
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+ {
+ showCutoffUnmetIcon &&
+ !!episodeFile &&
+ episodeFile.qualityCutoffNotMet &&
+
+ }
+
+ {
+ showCutoffUnmetIcon &&
+ !!episodeFile &&
+ episodeFile.languageCutoffNotMet &&
+ !episodeFile.qualityCutoffNotMet &&
+
+ }
+
+ {
+ episodeNumber === 1 && seasonNumber > 0 &&
+
+ }
+
+ {
+ showFinaleIcon &&
+ episodeNumber !== 1 &&
+ seasonNumber > 0 &&
+ episodeNumber === seasonStatistics.totalEpisodeCount &&
+
+ }
+
+ {
+ showSpecialIcon &&
+ (episodeNumber === 0 || seasonNumber === 0) &&
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+AgendaEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ series: PropTypes.object.isRequired,
+ episodeFile: PropTypes.object,
+ title: PropTypes.string.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ airDateUtc: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ showDate: PropTypes.bool.isRequired,
+ showEpisodeInformation: PropTypes.bool.isRequired,
+ showFinaleIcon: PropTypes.bool.isRequired,
+ showSpecialIcon: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default AgendaEvent;
diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
new file mode 100644
index 000000000..e1d996225
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
@@ -0,0 +1,30 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import AgendaEvent from './AgendaEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createSeriesSelector(),
+ createEpisodeFileSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (calendarOptions, series, episodeFile, queueItem, uiSettings) => {
+ return {
+ series,
+ episodeFile,
+ queueItem,
+ ...calendarOptions,
+ timeFormat: uiSettings.timeFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(AgendaEvent);
diff --git a/frontend/src/Calendar/Calendar.css b/frontend/src/Calendar/Calendar.css
new file mode 100644
index 000000000..37e6ff618
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.css
@@ -0,0 +1,8 @@
+.calendar {
+ flex-grow: 1;
+ width: 100%;
+}
+
+.calendarContent {
+ width: 100%;
+}
diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js
new file mode 100644
index 000000000..6ceb1f3bb
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import * as calendarViews from './calendarViews';
+import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
+import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
+import CalendarDaysConnector from './Day/CalendarDaysConnector';
+import AgendaConnector from './Agenda/AgendaConnector';
+import styles from './Calendar.css';
+
+class Calendar extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ view
+ } = this.props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && view === calendarViews.AGENDA &&
+
+ }
+
+ {
+ !error && isPopulated && view !== calendarViews.AGENDA &&
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+Calendar.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ view: PropTypes.string.isRequired
+};
+
+export default Calendar;
diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js
new file mode 100644
index 000000000..6b743c2c7
--- /dev/null
+++ b/frontend/src/Calendar/CalendarConnector.js
@@ -0,0 +1,195 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import * as calendarActions from 'Store/Actions/calendarActions';
+import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as commandNames from 'Commands/commandNames';
+import Calendar from './Calendar';
+
+const UPDATE_DELAY = 3600000; // 1 hour
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (state) => state.settings.ui.item.firstDayOfWeek,
+ createCommandExecutingSelector(commandNames.REFRESH_SERIES),
+ (calendar, firstDayOfWeek, isRefreshingSeries) => {
+ return {
+ ...calendar,
+ isRefreshingSeries,
+ firstDayOfWeek
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...calendarActions,
+ fetchEpisodeFiles,
+ clearEpisodeFiles,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class CalendarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchCalendar,
+ gotoCalendarToday
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchCalendar();
+ } else {
+ gotoCalendarToday();
+ }
+
+ this.scheduleUpdate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ time,
+ view,
+ isRefreshingSeries,
+ firstDayOfWeek
+ } = this.props;
+
+ if (hasDifferentItems(prevProps.items, items)) {
+ const episodeIds = selectUniqueIds(items, 'id');
+ const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
+
+ if (items.length) {
+ this.props.fetchQueueDetails({ episodeIds });
+ }
+
+ if (episodeFileIds.length) {
+ this.props.fetchEpisodeFiles({ episodeFileIds });
+ }
+ }
+
+ if (prevProps.time !== time) {
+ this.scheduleUpdate();
+ }
+
+ if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
+ this.props.fetchCalendar({ time, view });
+ }
+
+ if (prevProps.isRefreshingSeries && !isRefreshingSeries) {
+ this.props.fetchCalendar({ time, view });
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCalendar();
+ this.props.clearQueueDetails();
+ this.props.clearEpisodeFiles();
+ this.clearUpdateTimeout();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ const {
+ time,
+ view
+ } = this.props;
+
+ this.props.fetchQueueDetails({ time, view });
+ this.props.fetchCalendar({ time, view });
+ }
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+
+ this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
+ }
+
+ clearUpdateTimeout = () => {
+ if (this.updateTimeoutId) {
+ clearTimeout(this.updateTimeoutId);
+ }
+ }
+
+ updateCalendar = () => {
+ this.props.gotoCalendarToday();
+ this.scheduleUpdate();
+ }
+
+ //
+ // Listeners
+
+ onCalendarViewChange = (view) => {
+ this.props.setCalendarView({ view });
+ }
+
+ onTodayPress = () => {
+ this.props.gotoCalendarToday();
+ }
+
+ onPreviousPress = () => {
+ this.props.gotoCalendarPreviousRange();
+ }
+
+ onNextPress = () => {
+ this.props.gotoCalendarNextRange();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarConnector.propTypes = {
+ time: PropTypes.string,
+ view: PropTypes.string.isRequired,
+ firstDayOfWeek: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ setCalendarView: PropTypes.func.isRequired,
+ gotoCalendarToday: PropTypes.func.isRequired,
+ gotoCalendarPreviousRange: PropTypes.func.isRequired,
+ gotoCalendarNextRange: PropTypes.func.isRequired,
+ clearCalendar: PropTypes.func.isRequired,
+ fetchCalendar: PropTypes.func.isRequired,
+ fetchEpisodeFiles: PropTypes.func.isRequired,
+ clearEpisodeFiles: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);
diff --git a/frontend/src/Calendar/CalendarPage.css b/frontend/src/Calendar/CalendarPage.css
new file mode 100644
index 000000000..776f3100f
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.css
@@ -0,0 +1,14 @@
+.calendarPageBody {
+ composes: contentBody from 'Components/Page/PageContentBody.css';
+
+ display: flex;
+}
+
+.calendarInnerPageBody {
+ composes: innerContentBody from 'Components/Page/PageContentBody.css';
+
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ width: 100%;
+}
diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js
new file mode 100644
index 000000000..734bd88ff
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import Measure from 'Components/Measure';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import NoSeries from 'Series/NoSeries';
+import CalendarLinkModal from './iCal/CalendarLinkModal';
+import CalendarOptionsModal from './Options/CalendarOptionsModal';
+import LegendConnector from './Legend/LegendConnector';
+import CalendarConnector from './CalendarConnector';
+import styles from './CalendarPage.css';
+
+const MINIMUM_DAY_WIDTH = 120;
+
+class CalendarPage extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isCalendarLinkModalOpen: false,
+ isOptionsModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
+
+ this.props.onDaysCountChange(days);
+ }
+
+ onGetCalendarLinkPress = () => {
+ this.setState({ isCalendarLinkModalOpen: true });
+ }
+
+ onGetCalendarLinkModalClose = () => {
+ this.setState({ isCalendarLinkModalOpen: false });
+ }
+
+ onOptionsPress = () => {
+ this.setState({ isOptionsModalOpen: true });
+ }
+
+ onOptionsModalClose = () => {
+ this.setState({ isOptionsModalOpen: false });
+ }
+
+ onSearchMissingPress = () => {
+ const {
+ missingEpisodeIds,
+ onSearchMissingPress
+ } = this.props;
+
+ onSearchMissingPress(missingEpisodeIds);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedFilterKey,
+ filters,
+ hasSeries,
+ missingEpisodeIds,
+ isSearchingForMissing,
+ useCurrentPage,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ isCalendarLinkModalOpen,
+ isOptionsModalOpen
+ } = this.state;
+
+ const isMeasured = this.state.width > 0;
+ const PageComponent = hasSeries ? CalendarConnector : NoSeries;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isMeasured ?
+ :
+
+ }
+
+
+ {
+ hasSeries &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+CalendarPage.propTypes = {
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ hasSeries: PropTypes.bool.isRequired,
+ missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ isSearchingForMissing: PropTypes.bool.isRequired,
+ useCurrentPage: PropTypes.bool.isRequired,
+ onSearchMissingPress: PropTypes.func.isRequired,
+ onDaysCountChange: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+export default CalendarPage;
diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js
new file mode 100644
index 000000000..74261c0cd
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPageConnector.js
@@ -0,0 +1,101 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import moment from 'moment';
+import { isCommandExecuting } from 'Utilities/Command';
+import isBefore from 'Utilities/Date/isBefore';
+import withCurrentPage from 'Components/withCurrentPage';
+import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
+import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import CalendarPage from './CalendarPage';
+
+function createMissingEpisodeIdsSelector() {
+ return createSelector(
+ (state) => state.calendar.start,
+ (state) => state.calendar.end,
+ (state) => state.calendar.items,
+ (state) => state.queue.details.items,
+ (start, end, episodes, queueDetails) => {
+ return episodes.reduce((acc, episode) => {
+ const airDateUtc = episode.airDateUtc;
+
+ if (
+ !episode.episodeFileId &&
+ moment(airDateUtc).isAfter(start) &&
+ moment(airDateUtc).isBefore(end) &&
+ isBefore(episode.airDateUtc) &&
+ !queueDetails.some((details) => details.episode.id === episode.id)
+ ) {
+ acc.push(episode.id);
+ }
+
+ return acc;
+ }, []);
+ }
+ );
+}
+
+function createIsSearchingSelector() {
+ return createSelector(
+ (state) => state.calendar.searchMissingCommandId,
+ createCommandsSelector(),
+ (searchMissingCommandId, commands) => {
+ if (searchMissingCommandId == null) {
+ return false;
+ }
+
+ return isCommandExecuting(commands.find((command) => {
+ return command.id === searchMissingCommandId;
+ }));
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.selectedFilterKey,
+ (state) => state.calendar.filters,
+ createSeriesCountSelector(),
+ createUISettingsSelector(),
+ createMissingEpisodeIdsSelector(),
+ createIsSearchingSelector(),
+ (
+ selectedFilterKey,
+ filters,
+ seriesCount,
+ uiSettings,
+ missingEpisodeIds,
+ isSearchingForMissing
+ ) => {
+ return {
+ selectedFilterKey,
+ filters,
+ colorImpairedMode: uiSettings.enableColorImpairedMode,
+ hasSeries: !!seriesCount,
+ missingEpisodeIds,
+ isSearchingForMissing
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSearchMissingPress(episodeIds) {
+ dispatch(searchMissing({ episodeIds }));
+ },
+
+ onDaysCountChange(dayCount) {
+ dispatch(setCalendarDaysCount({ dayCount }));
+ },
+
+ onFilterSelect(selectedFilterKey) {
+ dispatch(setCalendarFilter({ selectedFilterKey }));
+ }
+ };
+}
+
+export default withCurrentPage(
+ connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
+);
diff --git a/frontend/src/Calendar/Day/CalendarDay.css b/frontend/src/Calendar/Day/CalendarDay.css
new file mode 100644
index 000000000..1c7694f0b
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.css
@@ -0,0 +1,25 @@
+.day {
+ flex: 1 0 14.28%;
+ overflow: hidden;
+ min-height: 70px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 1px solid $borderColor;
+}
+
+.isSingleDay {
+ width: 100%;
+}
+
+.dayOfMonth {
+ padding-right: 5px;
+ border-bottom: 1px solid $borderColor;
+ text-align: right;
+}
+
+.isToday {
+ background-color: $calendarTodayBackgroundColor;
+}
+
+.isDifferentMonth {
+ color: $disabledColor;
+}
diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js
new file mode 100644
index 000000000..faa45b28b
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.js
@@ -0,0 +1,74 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
+import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
+import styles from './CalendarDay.css';
+
+function CalendarDay(props) {
+ const {
+ date,
+ time,
+ isTodaysDate,
+ events,
+ view,
+ onEventModalOpenToggle
+ } = props;
+
+ return (
+
+ {
+ view === calendarViews.MONTH &&
+
+ {moment(date).date()}
+
+ }
+
+ {
+ events.map((event) => {
+ if (event.isGroup) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })
+ }
+
+
+ );
+}
+
+CalendarDay.propTypes = {
+ date: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ isTodaysDate: PropTypes.bool.isRequired,
+ events: PropTypes.arrayOf(PropTypes.object).isRequired,
+ view: PropTypes.string.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarDay;
diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js
new file mode 100644
index 000000000..8fd6cc5a1
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDayConnector.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import CalendarDay from './CalendarDay';
+
+function sort(items) {
+ return _.sortBy(items, (item) => {
+ if (item.isGroup) {
+ return moment(item.events[0].airDateUtc).unix();
+ }
+
+ return moment(item.airDateUtc).unix();
+ });
+}
+
+function createCalendarEventsConnector() {
+ return createSelector(
+ (state, { date }) => date,
+ (state) => state.calendar.items,
+ (state) => state.calendar.options.collapseMultipleEpisodes,
+ (date, items, collapseMultipleEpisodes) => {
+ const filtered = _.filter(items, (item) => {
+ return moment(date).isSame(moment(item.airDateUtc), 'day');
+ });
+
+ if (!collapseMultipleEpisodes) {
+ return sort(filtered);
+ }
+
+ const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`);
+ const grouped = [];
+
+ Object.keys(groupedObject).forEach((key) => {
+ const events = groupedObject[key];
+
+ if (events.length === 1) {
+ grouped.push(events[0]);
+ } else {
+ grouped.push({
+ isGroup: true,
+ seriesId: events[0].seriesId,
+ seasonNumber: events[0].seasonNumber,
+ episodeIds: events.map((event) => event.id),
+ events: _.sortBy(events, (item) => moment(item.airDateUtc).unix())
+ });
+ }
+ });
+
+ const sorted = sort(grouped);
+
+ return sorted;
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createCalendarEventsConnector(),
+ (calendar, events) => {
+ return {
+ time: calendar.time,
+ view: calendar.view,
+ events
+ };
+ }
+ );
+}
+
+class CalendarDayConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarDayConnector.propTypes = {
+ date: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(CalendarDayConnector);
diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css
new file mode 100644
index 000000000..22005e3e6
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.css
@@ -0,0 +1,14 @@
+.days {
+ display: flex;
+ border-right: 1px solid $borderColor;
+}
+
+.day,
+.week,
+.forecast {
+ flex-wrap: nowrap;
+}
+
+.month {
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js
new file mode 100644
index 000000000..0a1a36172
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.js
@@ -0,0 +1,164 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import isToday from 'Utilities/Date/isToday';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarDayConnector from './CalendarDayConnector';
+import styles from './CalendarDays.css';
+
+class CalendarDays extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._touchStart = null;
+
+ this.state = {
+ todaysDate: moment().startOf('day').toISOString(),
+ isEventModalOpen: false
+ };
+
+ this.updateTimeoutId = null;
+ }
+
+ // Lifecycle
+
+ componentDidMount() {
+ const view = this.props.view;
+
+ if (view === calendarViews.MONTH) {
+ this.scheduleUpdate();
+ }
+
+ window.addEventListener('touchstart', this.onTouchStart);
+ window.addEventListener('touchend', this.onTouchEnd);
+ window.addEventListener('touchcancel', this.onTouchCancel);
+ window.addEventListener('touchmove', this.onTouchMove);
+ }
+
+ componentWillUnmount() {
+ this.clearUpdateTimeout();
+
+ window.removeEventListener('touchstart', this.onTouchStart);
+ window.removeEventListener('touchend', this.onTouchEnd);
+ window.removeEventListener('touchcancel', this.onTouchCancel);
+ window.removeEventListener('touchmove', this.onTouchMove);
+ }
+
+ //
+ // Control
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+ const todaysDate = moment().startOf('day');
+ const diff = moment().diff(todaysDate.clone().add(1, 'day'));
+
+ this.setState({ todaysDate: todaysDate.toISOString() });
+
+ this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
+ }
+
+ clearUpdateTimeout = () => {
+ if (this.updateTimeoutId) {
+ clearTimeout(this.updateTimeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ onEventModalOpenToggle = (isEventModalOpen) => {
+ this.setState({ isEventModalOpen });
+ }
+
+ onTouchStart = (event) => {
+ const touches = event.touches;
+ const touchStart = touches[0].pageX;
+
+ if (touches.length !== 1) {
+ return;
+ }
+
+ if (
+ touchStart < 50 ||
+ this.props.isSidebarVisible ||
+ this.state.isEventModalOpen
+ ) {
+ return;
+ }
+
+ this._touchStart = touchStart;
+ }
+
+ onTouchEnd = (event) => {
+ const touches = event.changedTouches;
+ const currentTouch = touches[0].pageX;
+
+ if (!this._touchStart) {
+ return;
+ }
+
+ if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
+ this.props.onNavigatePrevious();
+ } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
+ this.props.onNavigateNext();
+ }
+
+ this._touchStart = null;
+ }
+
+ onTouchCancel = (event) => {
+ this._touchStart = null;
+ }
+
+ onTouchMove = (event) => {
+ if (!this._touchStart) {
+ return;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dates,
+ view
+ } = this.props;
+
+ return (
+
+ {
+ dates.map((date) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+CalendarDays.propTypes = {
+ dates: PropTypes.arrayOf(PropTypes.string).isRequired,
+ view: PropTypes.string.isRequired,
+ isSidebarVisible: PropTypes.bool.isRequired,
+ onNavigatePrevious: PropTypes.func.isRequired,
+ onNavigateNext: PropTypes.func.isRequired
+};
+
+export default CalendarDays;
diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js
new file mode 100644
index 000000000..3dea906a7
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions';
+import CalendarDays from './CalendarDays';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (state) => state.app.isSidebarVisible,
+ (calendar, isSidebarVisible) => {
+ return {
+ dates: calendar.dates,
+ view: calendar.view,
+ isSidebarVisible
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onNavigatePrevious: gotoCalendarPreviousRange,
+ onNavigateNext: gotoCalendarNextRange
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);
diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css
new file mode 100644
index 000000000..8c3552e55
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.css
@@ -0,0 +1,13 @@
+.dayOfWeek {
+ flex: 1 0 14.28%;
+ background-color: #e4eaec;
+ text-align: center;
+}
+
+.isSingleDay {
+ width: 100%;
+}
+
+.isToday {
+ background-color: $calendarTodayBackgroundColor;
+}
diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js
new file mode 100644
index 000000000..d97671522
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.js
@@ -0,0 +1,56 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import * as calendarViews from 'Calendar/calendarViews';
+import styles from './DayOfWeek.css';
+
+class DayOfWeek extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ date,
+ view,
+ isTodaysDate,
+ calendarWeekColumnHeader,
+ shortDateFormat,
+ showRelativeDates
+ } = this.props;
+
+ const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
+ const momentDate = moment(date);
+ let formatedDate = momentDate.format('dddd');
+
+ if (view === calendarViews.WEEK) {
+ formatedDate = momentDate.format(calendarWeekColumnHeader);
+ } else if (view === calendarViews.FORECAST) {
+ formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates);
+ }
+
+ return (
+
+ {formatedDate}
+
+ );
+ }
+}
+
+DayOfWeek.propTypes = {
+ date: PropTypes.string.isRequired,
+ view: PropTypes.string.isRequired,
+ isTodaysDate: PropTypes.bool.isRequired,
+ calendarWeekColumnHeader: PropTypes.string.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired
+};
+
+export default DayOfWeek;
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css b/frontend/src/Calendar/Day/DaysOfWeek.css
new file mode 100644
index 000000000..518664633
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeek.css
@@ -0,0 +1,4 @@
+.daysOfWeek {
+ display: flex;
+ margin-top: 10px;
+}
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js
new file mode 100644
index 000000000..a67777f7c
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeek.js
@@ -0,0 +1,97 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import DayOfWeek from './DayOfWeek';
+import * as calendarViews from 'Calendar/calendarViews';
+import styles from './DaysOfWeek.css';
+
+class DaysOfWeek extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ todaysDate: moment().startOf('day').toISOString()
+ };
+
+ this.updateTimeoutId = null;
+ }
+
+ // Lifecycle
+
+ componentDidMount() {
+ const view = this.props.view;
+
+ if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
+ this.scheduleUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ this.clearUpdateTimeout();
+ }
+
+ //
+ // Control
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+ const todaysDate = moment().startOf('day');
+ const diff = todaysDate.clone().add(1, 'day').diff(moment());
+
+ this.setState({
+ todaysDate: todaysDate.toISOString()
+ });
+
+ this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
+ }
+
+ clearUpdateTimeout = () => {
+ if (this.updateTimeoutId) {
+ clearTimeout(this.updateTimeoutId);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dates,
+ view,
+ ...otherProps
+ } = this.props;
+
+ if (view === calendarViews.AGENDA) {
+ return null;
+ }
+
+ return (
+
+ {
+ dates.map((date) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+DaysOfWeek.propTypes = {
+ dates: PropTypes.arrayOf(PropTypes.string),
+ view: PropTypes.string.isRequired
+};
+
+export default DaysOfWeek;
diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js
new file mode 100644
index 000000000..7f5cdef19
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeekConnector.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import DaysOfWeek from './DaysOfWeek';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createUISettingsSelector(),
+ (calendar, UiSettings) => {
+ return {
+ dates: calendar.dates.slice(0, 7),
+ view: calendar.view,
+ calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader,
+ shortDateFormat: UiSettings.shortDateFormat,
+ showRelativeDates: UiSettings.showRelativeDates
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(DaysOfWeek);
diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css
new file mode 100644
index 000000000..c135dbc5b
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.css
@@ -0,0 +1,78 @@
+.event {
+ overflow-x: hidden;
+ margin: 4px 2px;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 4px solid $borderColor;
+ font-size: 12px;
+}
+
+.info,
+.episodeInfo {
+ display: flex;
+}
+
+.seriesTitle,
+.episodeTitle {
+ @add-mixin truncate;
+
+ flex: 1 0 1px;
+ margin-right: 10px;
+}
+
+.seriesTitle {
+ color: #3a3f51;
+ font-size: $defaultFontSize;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ border-left-color: $successColor !important;
+}
+
+.downloading {
+ border-left-color: $purple !important;
+}
+
+.unmonitored {
+ border-left-color: $gray !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.onAir {
+ border-left-color: $warningColor !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.missing {
+ border-left-color: $dangerColor !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.unaired {
+ border-left-color: $primaryColor !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js
new file mode 100644
index 000000000..d76e6da3f
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.js
@@ -0,0 +1,244 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import episodeEntities from 'Episode/episodeEntities';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
+import CalendarEventQueueDetails from './CalendarEventQueueDetails';
+import styles from './CalendarEvent.css';
+
+class CalendarEvent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isDetailsModalOpen: true }, () => {
+ this.props.onEventModalOpenToggle(true);
+ });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false }, () => {
+ this.props.onEventModalOpenToggle(false);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ series,
+ episodeFile,
+ title,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDateUtc,
+ monitored,
+ hasFile,
+ grabbed,
+ queueItem,
+ showEpisodeInformation,
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ timeFormat,
+ colorImpairedMode
+ } = this.props;
+
+ if (!series) {
+ return null;
+ }
+
+ const startTime = moment(airDateUtc);
+ const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
+ const isDownloading = !!(queueItem || grabbed);
+ const isMonitored = series.monitored && monitored;
+ const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored);
+ const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
+ const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
+ const seasonStatistics = season.statistics || {};
+
+ return (
+
+
+
+
+ {series.title}
+
+
+ {
+ missingAbsoluteNumber &&
+
+ }
+
+ {
+ !!queueItem &&
+
+
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+ {
+ showCutoffUnmetIcon &&
+ !!episodeFile &&
+ episodeFile.qualityCutoffNotMet &&
+
+ }
+
+ {
+ showCutoffUnmetIcon &&
+ !!episodeFile &&
+ episodeFile.languageCutoffNotMet &&
+ !episodeFile.qualityCutoffNotMet &&
+
+ }
+
+ {
+ episodeNumber === 1 && seasonNumber > 0 &&
+
+ }
+
+ {
+ showFinaleIcon &&
+ episodeNumber !== 1 &&
+ seasonNumber > 0 &&
+ episodeNumber === seasonStatistics.totalEpisodeCount &&
+
+ }
+
+ {
+ showSpecialIcon &&
+ (episodeNumber === 0 || seasonNumber === 0) &&
+
+ }
+
+
+ {
+ showEpisodeInformation &&
+
+
+ {title}
+
+
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ {
+ series.seriesType === 'anime' && absoluteEpisodeNumber &&
+ ({absoluteEpisodeNumber})
+ }
+
+
+ }
+
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
+
+
+
+
+
+ );
+ }
+}
+
+CalendarEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ series: PropTypes.object.isRequired,
+ episodeFile: PropTypes.object,
+ title: PropTypes.string.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ airDateUtc: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ showEpisodeInformation: PropTypes.bool.isRequired,
+ showFinaleIcon: PropTypes.bool.isRequired,
+ showSpecialIcon: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarEvent;
diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js
new file mode 100644
index 000000000..f3b663dae
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventConnector.js
@@ -0,0 +1,29 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarEvent from './CalendarEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createSeriesSelector(),
+ createEpisodeFileSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (calendarOptions, series, episodeFile, queueItem, uiSettings) => {
+ return {
+ series,
+ episodeFile,
+ queueItem,
+ ...calendarOptions,
+ timeFormat: uiSettings.timeFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarEvent);
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css
new file mode 100644
index 000000000..7d1c64b9b
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.css
@@ -0,0 +1,82 @@
+.eventGroup {
+ overflow-x: hidden;
+ margin: 4px 2px;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 4px solid $borderColor;
+ font-size: 12px;
+}
+
+.info,
+.airingInfo {
+ display: flex;
+}
+
+.seriesTitle {
+ @add-mixin truncate;
+
+ flex: 1 0 1px;
+ margin-right: 10px;
+ color: #3a3f51;
+ font-size: $defaultFontSize;
+}
+
+.airTime {
+ flex: 1 0 1px;
+}
+
+.episodeInfo {
+ margin-left: 10px;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.expandContainerInline {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 0 20px;
+}
+
+.expandContainer,
+.collapseContainer {
+ display: flex;
+ justify-content: center;
+}
+
+.collapseContainer {
+ margin-bottom: 5px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from 'Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
+}
+
+.onAir {
+ composes: onAir from 'Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from 'Calendar/Events/CalendarEvent.css';
+}
+
+.premiere {
+ composes: premiere from 'Calendar/Events/CalendarEvent.css';
+}
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js
new file mode 100644
index 000000000..147440e94
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.js
@@ -0,0 +1,245 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
+import styles from './CalendarEventGroup.css';
+
+function getEventsInfo(events) {
+ let files = 0;
+ let queued = 0;
+ let monitored = 0;
+ let absoluteEpisodeNumbers = 0;
+
+ events.forEach((event) => {
+ if (event.episodeFileId) {
+ files++;
+ }
+
+ if (event.queued) {
+ queued++;
+ }
+
+ if (event.monitored) {
+ monitored++;
+ }
+
+ if (event.absoluteEpisodeNumber) {
+ absoluteEpisodeNumbers++;
+ }
+ });
+
+ return {
+ allDownloaded: files === events.length,
+ anyQueued: queued > 0,
+ anyMonitored: monitored > 0,
+ allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length
+ };
+}
+
+class CalendarEventGroup extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isExpanded: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onExpandPress = () => {
+ this.setState({ isExpanded: !this.state.isExpanded });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ series,
+ events,
+ isDownloading,
+ showEpisodeInformation,
+ showFinaleIcon,
+ timeFormat,
+ colorImpairedMode,
+ onEventModalOpenToggle
+ } = this.props;
+
+ const { isExpanded } = this.state;
+ const {
+ allDownloaded,
+ anyQueued,
+ anyMonitored,
+ allAbsoluteEpisodeNumbers
+ } = getEventsInfo(events);
+ const anyDownloading = isDownloading || anyQueued;
+ const firstEpisode = events[0];
+ const lastEpisode = events[events.length -1];
+ const airDateUtc = firstEpisode.airDateUtc;
+ const startTime = moment(airDateUtc);
+ const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
+ const seasonNumber = firstEpisode.seasonNumber;
+ const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored);
+ const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers;
+
+ if (isExpanded) {
+ return (
+
+ {
+ events.map((event) => {
+ if (event.isGroup) {
+ return null;
+ }
+
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {series.title}
+
+
+ {
+ isMissingAbsoluteNumber &&
+
+ }
+
+ {
+ anyDownloading &&
+
+ }
+
+ {
+ firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
+
+ }
+
+ {
+ showFinaleIcon &&
+ lastEpisode.episodeNumber !== 1 &&
+ seasonNumber > 0 &&
+ lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount &&
+
+ }
+
+
+
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
+
+
+ {
+ showEpisodeInformation ?
+
+ {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)}
+
+ {
+ series.seriesType === 'anime' &&
+ firstEpisode.absoluteEpisodeNumber &&
+ lastEpisode.absoluteEpisodeNumber &&
+
+ ({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber})
+
+ }
+
:
+
+
+
+ }
+
+
+ {
+ showEpisodeInformation &&
+
+
+
+ }
+
+ );
+ }
+}
+
+CalendarEventGroup.propTypes = {
+ series: PropTypes.object.isRequired,
+ events: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDownloading: PropTypes.bool.isRequired,
+ showEpisodeInformation: PropTypes.bool.isRequired,
+ showFinaleIcon: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarEventGroup;
diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js
new file mode 100644
index 000000000..731038d58
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js
@@ -0,0 +1,37 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarEventGroup from './CalendarEventGroup';
+
+function createIsDownloadingSelector() {
+ return createSelector(
+ (state, { episodeIds }) => episodeIds,
+ (state) => state.queue.details,
+ (episodeIds, details) => {
+ return details.items.some((item) => {
+ return episodeIds.includes(item.episode.id);
+ });
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createSeriesSelector(),
+ createIsDownloadingSelector(),
+ createUISettingsSelector(),
+ (calendarOptions, series, isDownloading, uiSettings) => {
+ return {
+ series,
+ isDownloading,
+ ...calendarOptions,
+ timeFormat: uiSettings.timeFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarEventGroup);
diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
new file mode 100644
index 000000000..81d81465c
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import colors from 'Styles/Variables/colors';
+import CircularProgressBar from 'Components/CircularProgressBar';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+
+function CalendarEventQueueDetails(props) {
+ const {
+ title,
+ size,
+ sizeleft,
+ estimatedCompletionTime,
+ status,
+ errorMessage
+ } = props;
+
+ const progress = (100 - sizeleft / size * 100);
+
+ return (
+
+
+
+ }
+ />
+ );
+}
+
+CalendarEventQueueDetails.propTypes = {
+ title: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ estimatedCompletionTime: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ errorMessage: PropTypes.string
+};
+
+export default CalendarEventQueueDetails;
diff --git a/frontend/src/Calendar/Header/CalendarHeader.css b/frontend/src/Calendar/Header/CalendarHeader.css
new file mode 100644
index 000000000..1127bb3c3
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.css
@@ -0,0 +1,53 @@
+.header {
+ display: flex;
+}
+
+.navigationButtons {
+ flex: 1 1 33%;
+ text-align: left;
+}
+
+.todayButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-left: 5px;
+}
+
+.titleDesktop,
+.titleMobile {
+ text-align: center;
+ font-size: 18px;
+}
+
+.titleMobile {
+ margin-bottom: 5px;
+}
+
+.viewButtonsContainer {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 1 33%;
+}
+
+.viewMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ line-height: 31px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 5px;
+ margin-right: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .navigationButtons {
+ flex: 1 0 50%;
+ }
+
+ .viewButtonsContainer {
+ flex: 0 0 100px;
+ }
+}
diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js
new file mode 100644
index 000000000..4fea8356d
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.js
@@ -0,0 +1,253 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import ViewMenuItem from 'Components/Menu/ViewMenuItem';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarHeaderViewButton from './CalendarHeaderViewButton';
+import styles from './CalendarHeader.css';
+
+function getTitle(time, start, end, view, longDateFormat) {
+ const timeMoment = moment(time);
+ const startMoment = moment(start);
+ const endMoment = moment(end);
+
+ if (view === 'day') {
+ return timeMoment.format(longDateFormat);
+ } else if (view === 'month') {
+ return timeMoment.format('MMMM YYYY');
+ } else if (view === 'agenda') {
+ return 'Agenda';
+ }
+
+ let startFormat = 'MMM D YYYY';
+ let endFormat = 'MMM D YYYY';
+
+ if (startMoment.isSame(endMoment, 'month')) {
+ startFormat = 'MMM D';
+ endFormat = 'D YYYY';
+ } else if (startMoment.isSame(endMoment, 'year')) {
+ startFormat = 'MMM D';
+ endFormat = 'MMM D YYYY';
+ }
+
+ return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
+}
+
+// TODO Convert to a stateful Component so we can track view internally when changed
+
+class CalendarHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ view: props.view
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const view = this.props.view;
+
+ if (prevProps.view !== view) {
+ this.setState({ view });
+ }
+ }
+
+ //
+ // Listeners
+
+ onViewChange = (view) => {
+ this.setState({ view }, () => {
+ this.props.onViewChange(view);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ time,
+ start,
+ end,
+ longDateFormat,
+ isSmallScreen,
+ onTodayPress,
+ onPreviousPress,
+ onNextPress
+ } = this.props;
+
+ const view = this.state.view;
+
+ const title = getTitle(time, start, end, view, longDateFormat);
+
+ return (
+
+ {
+ isSmallScreen &&
+
+ {title}
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ Today
+
+
+
+ {
+ !isSmallScreen &&
+
+ {title}
+
+ }
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ isSmallScreen ?
+
+
+
+
+
+
+
+ Week
+
+
+
+ Forecast
+
+
+
+ Day
+
+
+
+ Agenda
+
+
+ :
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+ );
+ }
+}
+
+CalendarHeader.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ time: PropTypes.string.isRequired,
+ start: PropTypes.string.isRequired,
+ end: PropTypes.string.isRequired,
+ view: PropTypes.oneOf(calendarViews.all).isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ onViewChange: PropTypes.func.isRequired,
+ onTodayPress: PropTypes.func.isRequired,
+ onPreviousPress: PropTypes.func.isRequired,
+ onNextPress: PropTypes.func.isRequired
+};
+
+export default CalendarHeader;
diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js
new file mode 100644
index 000000000..c96cf2869
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js
@@ -0,0 +1,84 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { setCalendarView, gotoCalendarToday, gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions';
+import CalendarHeader from './CalendarHeader';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createDimensionsSelector(),
+ createUISettingsSelector(),
+ (calendar, dimensions, uiSettings) => {
+ const result = _.pick(calendar, [
+ 'isFetching',
+ 'view',
+ 'time',
+ 'start',
+ 'end'
+ ]);
+
+ result.isSmallScreen = dimensions.isSmallScreen;
+ result.longDateFormat = uiSettings.longDateFormat;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setCalendarView,
+ gotoCalendarToday,
+ gotoCalendarPreviousRange,
+ gotoCalendarNextRange
+};
+
+class CalendarHeaderConnector extends Component {
+
+ //
+ // Listeners
+
+ onViewChange = (view) => {
+ this.props.setCalendarView({ view });
+ }
+
+ onTodayPress = () => {
+ this.props.gotoCalendarToday();
+ }
+
+ onPreviousPress = () => {
+ this.props.gotoCalendarPreviousRange();
+ }
+
+ onNextPress = () => {
+ this.props.gotoCalendarNextRange();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarHeaderConnector.propTypes = {
+ setCalendarView: PropTypes.func.isRequired,
+ gotoCalendarToday: PropTypes.func.isRequired,
+ gotoCalendarPreviousRange: PropTypes.func.isRequired,
+ gotoCalendarNextRange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);
diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js
new file mode 100644
index 000000000..8dd5ae9f0
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import Button from 'Components/Link/Button';
+import * as calendarViews from 'Calendar/calendarViews';
+// import styles from './CalendarHeaderViewButton.css';
+
+class CalendarHeaderViewButton extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.view);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ view,
+ selectedView,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {titleCase(view)}
+
+ );
+ }
+}
+
+CalendarHeaderViewButton.propTypes = {
+ view: PropTypes.oneOf(calendarViews.all).isRequired,
+ selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default CalendarHeaderViewButton;
diff --git a/frontend/src/Calendar/Legend/Legend.css b/frontend/src/Calendar/Legend/Legend.css
new file mode 100644
index 000000000..296cbd9d5
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.css
@@ -0,0 +1,6 @@
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 10px;
+ padding: 3px 0;
+}
diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js
new file mode 100644
index 000000000..57d3bad2a
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import LegendItem from './LegendItem';
+import LegendIconItem from './LegendIconItem';
+import styles from './Legend.css';
+
+function Legend(props) {
+ const {
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ colorImpairedMode
+ } = props;
+
+ const iconsToShow = [];
+ if (showFinaleIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ if (showSpecialIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ if (showCutoffUnmetIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {iconsToShow[0]}
+
+
+ {
+ iconsToShow.length > 1 &&
+
+ {iconsToShow[1]}
+ {iconsToShow[2]}
+
+ }
+
+ );
+}
+
+Legend.propTypes = {
+ showFinaleIcon: PropTypes.bool.isRequired,
+ showSpecialIcon: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default Legend;
diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js
new file mode 100644
index 000000000..30bbc4adb
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendConnector.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import Legend from './Legend';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createUISettingsSelector(),
+ (calendarOptions, uiSettings) => {
+ return {
+ ...calendarOptions,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Legend);
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css
new file mode 100644
index 000000000..01db0ba5a
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendIconItem.css
@@ -0,0 +1,10 @@
+.legendIconItem {
+ margin: 3px 0;
+ margin-right: 6px;
+ width: 150px;
+ cursor: default;
+}
+
+.icon {
+ margin-right: 5px;
+}
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js
new file mode 100644
index 000000000..2074a9b1e
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendIconItem.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import styles from './legendIconItem.css';
+
+function LegendIconItem(props) {
+ const {
+ name,
+ icon,
+ kind,
+ tooltip
+ } = props;
+
+ return (
+
+
+
+ {name}
+
+ );
+}
+
+LegendIconItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ icon: PropTypes.object.isRequired,
+ kind: PropTypes.string.isRequired,
+ tooltip: PropTypes.string.isRequired
+};
+
+export default LegendIconItem;
diff --git a/frontend/src/Calendar/Legend/LegendItem.css b/frontend/src/Calendar/Legend/LegendItem.css
new file mode 100644
index 000000000..d146e9d68
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.css
@@ -0,0 +1,41 @@
+.legendItem {
+ margin: 3px 0;
+ margin-right: 6px;
+ padding-left: 5px;
+ width: 150px;
+ border-left-width: 4px;
+ border-left-style: solid;
+ cursor: default;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from 'Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
+}
+
+.onAir {
+ composes: onAir from 'Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from 'Calendar/Events/CalendarEvent.css';
+}
+
+.premiere {
+ composes: premiere from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unaired {
+ composes: unaired from 'Calendar/Events/CalendarEvent.css';
+}
diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js
new file mode 100644
index 000000000..961f48b86
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import titleCase from 'Utilities/String/titleCase';
+import styles from './LegendItem.css';
+
+function LegendItem(props) {
+ const {
+ name,
+ status,
+ tooltip,
+ colorImpairedMode
+ } = props;
+
+ return (
+
+ {name ? name : titleCase(status)}
+
+ );
+}
+
+LegendItem.propTypes = {
+ name: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ tooltip: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default LegendItem;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js
new file mode 100644
index 000000000..b68c83f30
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
+
+function CalendarOptionsModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+CalendarOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarOptionsModal;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js
new file mode 100644
index 000000000..9ff7b3f82
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js
@@ -0,0 +1,258 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+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 { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings';
+
+class CalendarOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ } = props;
+
+ this.state = {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ } = this.props;
+
+ if (
+ prevProps.firstDayOfWeek !== firstDayOfWeek ||
+ prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
+ prevProps.timeFormat !== timeFormat ||
+ prevProps.enableColorImpairedMode !== enableColorImpairedMode
+ ) {
+ this.setState({
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onOptionInputChange = ({ name, value }) => {
+ const {
+ dispatchSetCalendarOption
+ } = this.props;
+
+ dispatchSetCalendarOption({ [name]: value });
+ }
+
+ onGlobalInputChange = ({ name, value }) => {
+ const {
+ dispatchSaveUISettings
+ } = this.props;
+
+ const setting = { [name]: value };
+
+ this.setState(setting, () => {
+ dispatchSaveUISettings(setting);
+ });
+ }
+
+ onLinkFocus = (event) => {
+ event.target.select();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ collapseMultipleEpisodes,
+ showEpisodeInformation,
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ onModalClose
+ } = this.props;
+
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ } = this.state;
+
+ return (
+
+
+ Calendar Options
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+CalendarOptionsModalContent.propTypes = {
+ collapseMultipleEpisodes: PropTypes.bool.isRequired,
+ showEpisodeInformation: PropTypes.bool.isRequired,
+ showFinaleIcon: PropTypes.bool.isRequired,
+ showSpecialIcon: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ firstDayOfWeek: PropTypes.number.isRequired,
+ calendarWeekColumnHeader: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ enableColorImpairedMode: PropTypes.bool.isRequired,
+ dispatchSetCalendarOption: PropTypes.func.isRequired,
+ dispatchSaveUISettings: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarOptionsModalContent;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
new file mode 100644
index 000000000..eb979f74e
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setCalendarOption } from 'Store/Actions/calendarActions';
+import CalendarOptionsModalContent from './CalendarOptionsModalContent';
+import { saveUISettings } from 'Store/Actions/settingsActions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ (state) => state.settings.ui.item,
+ (options, uiSettings) => {
+ return {
+ ...options,
+ ...uiSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetCalendarOption: setCalendarOption,
+ dispatchSaveUISettings: saveUISettings
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);
diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js
new file mode 100644
index 000000000..929958b66
--- /dev/null
+++ b/frontend/src/Calendar/calendarViews.js
@@ -0,0 +1,7 @@
+export const DAY = 'day';
+export const WEEK = 'week';
+export const MONTH = 'month';
+export const FORECAST = 'forecast';
+export const AGENDA = 'agenda';
+
+export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js
new file mode 100644
index 000000000..b149a8aab
--- /dev/null
+++ b/frontend/src/Calendar/getStatusStyle.js
@@ -0,0 +1,30 @@
+/* eslint max-params: 0 */
+import moment from 'moment';
+
+function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) {
+ const currentTime = moment();
+
+ if (hasFile) {
+ return 'downloaded';
+ }
+
+ if (downloading) {
+ return 'downloading';
+ }
+
+ if (!isMonitored) {
+ return 'unmonitored';
+ }
+
+ if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) {
+ return 'onAir';
+ }
+
+ if (endTime.isBefore(currentTime) && !hasFile) {
+ return 'missing';
+ }
+
+ return 'unaired';
+}
+
+export default getStatusStyle;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js
new file mode 100644
index 000000000..8cc487c16
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
+
+function CalendarLinkModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+CalendarLinkModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarLinkModal;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
new file mode 100644
index 000000000..e965b862d
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
@@ -0,0 +1,221 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import ClipboardButton from 'Components/Link/ClipboardButton';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormInputButton from 'Components/Form/FormInputButton';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function getUrls(state) {
+ const {
+ unmonitored,
+ premieresOnly,
+ asAllDay,
+ tags
+ } = state;
+
+ let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/calendar/Sonarr.ics?`;
+
+ if (unmonitored) {
+ icalUrl += 'unmonitored=true&';
+ }
+
+ if (premieresOnly) {
+ icalUrl += 'premieresOnly=true&';
+ }
+
+ if (asAllDay) {
+ icalUrl += 'asAllDay=true&';
+ }
+
+ if (tags.length) {
+ icalUrl += `tags=${tags.toString()}&`;
+ }
+
+ icalUrl += `apikey=${window.Sonarr.apiKey}`;
+
+ const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
+ const iCalWebCalUrl = `webcal://${icalUrl}`;
+
+ return {
+ iCalHttpUrl,
+ iCalWebCalUrl
+ };
+}
+
+class CalendarLinkModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const defaultState = {
+ unmonitored: false,
+ premieresOnly: false,
+ asAllDay: false,
+ tags: []
+ };
+
+ const urls = getUrls(defaultState);
+
+ this.state = {
+ ...defaultState,
+ ...urls
+ };
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ const state = {
+ ...this.state,
+ [name]: value
+ };
+
+ const urls = getUrls(state);
+
+ this.setState({
+ [name]: value,
+ ...urls
+ });
+ }
+
+ onLinkFocus = (event) => {
+ event.target.select();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ unmonitored,
+ premieresOnly,
+ asAllDay,
+ tags,
+ iCalHttpUrl,
+ iCalWebCalUrl
+ } = this.state;
+
+ return (
+
+
+ Sonarr Calendar Feed
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+CalendarLinkModalContent.propTypes = {
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarLinkModalContent;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js
new file mode 100644
index 000000000..e10c5c3f9
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import CalendarLinkModalContent from './CalendarLinkModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagsSelector(),
+ (tagList) => {
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarLinkModalContent);
diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js
new file mode 100644
index 000000000..a48c59157
--- /dev/null
+++ b/frontend/src/Commands/commandNames.js
@@ -0,0 +1,20 @@
+export const APPLICATION_UPDATE = 'ApplicationUpdate';
+export const BACKUP = 'Backup';
+export const CHECK_FOR_FINISHED_DOWNLOAD = 'CheckForFinishedDownload';
+export const CLEAR_BLACKLIST = 'ClearBlacklist';
+export const CLEAR_LOGS = 'ClearLog';
+export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch';
+export const DELETE_LOG_FILES = 'DeleteLogFiles';
+export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles';
+export const DOWNLOADED_EPSIODES_SCAN = 'DownloadedEpisodesScan';
+export const EPISODE_SEARCH = 'EpisodeSearch';
+export const INTERACTIVE_IMPORT = 'ManualImport';
+export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch';
+export const MOVE_SERIES = 'MoveSeries';
+export const REFRESH_SERIES = 'RefreshSeries';
+export const RENAME_FILES = 'RenameFiles';
+export const RENAME_SERIES = 'RenameSeries';
+export const RESET_API_KEY = 'ResetApiKey';
+export const RSS_SYNC = 'RssSync';
+export const SEASON_SEARCH = 'SeasonSearch';
+export const SERIES_SEARCH = 'SeriesSearch';
diff --git a/frontend/src/Components/Alert.css b/frontend/src/Components/Alert.css
new file mode 100644
index 000000000..312fbb4f2
--- /dev/null
+++ b/frontend/src/Components/Alert.css
@@ -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;
+}
diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js
new file mode 100644
index 000000000..dc19a418c
--- /dev/null
+++ b/frontend/src/Components/Alert.js
@@ -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 (
+
+ {children}
+
+ );
+}
+
+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;
diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css
new file mode 100644
index 000000000..b54bbcdf4
--- /dev/null
+++ b/frontend/src/Components/Card.css
@@ -0,0 +1,19 @@
+.card {
+ position: relative;
+ margin: 10px;
+ padding: 10px;
+ border-radius: 3px;
+ background-color: $white;
+ box-shadow: 0 0 10px 1px $cardShadowColor;
+ color: $defaultColor;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ position: relative;
+}
diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js
new file mode 100644
index 000000000..c5a4d164c
--- /dev/null
+++ b/frontend/src/Components/Card.js
@@ -0,0 +1,60 @@
+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,
+ overlayClassName,
+ overlayContent,
+ children,
+ onPress
+ } = this.props;
+
+ if (overlayContent) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+Card.propTypes = {
+ className: PropTypes.string.isRequired,
+ overlayClassName: PropTypes.string.isRequired,
+ overlayContent: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+Card.defaultProps = {
+ className: styles.card,
+ overlayClassName: styles.overlay,
+ overlayContent: false
+};
+
+export default Card;
diff --git a/frontend/src/Components/CircularProgressBar.css b/frontend/src/Components/CircularProgressBar.css
new file mode 100644
index 000000000..32b349404
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.css
@@ -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;
+}
diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js
new file mode 100644
index 000000000..95c84f59a
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.js
@@ -0,0 +1,139 @@
+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 (
+
+
+
+
+
+ {
+ showProgressText &&
+
+ {progressText}
+
+ }
+
+ );
+ }
+}
+
+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;
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css b/frontend/src/Components/DescriptionList/DescriptionList.css
new file mode 100644
index 000000000..230347f80
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.css
@@ -0,0 +1,4 @@
+.descriptionList {
+ margin-top: 0;
+ margin-bottom: 0;
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js
new file mode 100644
index 000000000..be2c87c55
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './DescriptionList.css';
+
+class DescriptionList extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+DescriptionList.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node
+};
+
+DescriptionList.defaultProps = {
+ className: styles.descriptionList
+};
+
+export default DescriptionList;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js
new file mode 100644
index 000000000..4ba70bf33
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js
@@ -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 (
+
+
+ {title}
+
+
+
+ {data}
+
+
+ );
+ }
+}
+
+DescriptionListItem.propTypes = {
+ titleClassName: PropTypes.string,
+ descriptionClassName: PropTypes.string,
+ title: PropTypes.string,
+ data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
+};
+
+export default DescriptionListItem;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
new file mode 100644
index 000000000..b23415a76
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
@@ -0,0 +1,13 @@
+.description {
+ line-height: $lineHeight;
+}
+
+.description {
+ margin-left: 0;
+}
+
+@media (min-width: 768px) {
+ .description {
+ margin-left: 180px;
+ }
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js
new file mode 100644
index 000000000..4ef3c015e
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js
@@ -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 (
+
+ {children}
+
+ );
+}
+
+DescriptionListItemDescription.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
+};
+
+DescriptionListItemDescription.defaultProps = {
+ className: styles.description
+};
+
+export default DescriptionListItemDescription;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css
new file mode 100644
index 000000000..e496e463d
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css
@@ -0,0 +1,18 @@
+.title {
+ line-height: $lineHeight;
+}
+
+.title {
+ font-weight: bold;
+}
+
+@media (min-width: 768px) {
+ .title {
+ @add-mixin truncate;
+
+ float: left;
+ clear: left;
+ width: 160px;
+ text-align: right;
+ }
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js
new file mode 100644
index 000000000..e1632c1cf
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js
@@ -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 (
+
+ {children}
+
+ );
+}
+
+DescriptionListItemTitle.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.string
+};
+
+DescriptionListItemTitle.defaultProps = {
+ className: styles.title
+};
+
+export default DescriptionListItemTitle;
diff --git a/frontend/src/Components/DragPreviewLayer.css b/frontend/src/Components/DragPreviewLayer.css
new file mode 100644
index 000000000..46f721fef
--- /dev/null
+++ b/frontend/src/Components/DragPreviewLayer.css
@@ -0,0 +1,9 @@
+.dragLayer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 9999;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js
new file mode 100644
index 000000000..a111df70e
--- /dev/null
+++ b/frontend/src/Components/DragPreviewLayer.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './DragPreviewLayer.css';
+
+function DragPreviewLayer({ children, ...otherProps }) {
+ return (
+
+ {children}
+
+ );
+}
+
+DragPreviewLayer.propTypes = {
+ children: PropTypes.node,
+ className: PropTypes.string
+};
+
+DragPreviewLayer.defaultProps = {
+ className: styles.dragLayer
+};
+
+export default DragPreviewLayer;
diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js
new file mode 100644
index 000000000..50fcf98b5
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundary.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import * as sentry from '@sentry/browser';
+
+class ErrorBoundary extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ error: null,
+ info: null
+ };
+ }
+
+ componentDidCatch(error, info) {
+ this.setState({
+ error,
+ info
+ });
+
+ sentry.captureException(error);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ errorComponent: ErrorComponent,
+ ...otherProps
+ } = this.props;
+
+ const {
+ error,
+ info
+ } = this.state;
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return children;
+ }
+}
+
+ErrorBoundary.propTypes = {
+ children: PropTypes.node.isRequired,
+ errorComponent: PropTypes.func.isRequired
+};
+
+export default ErrorBoundary;
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css
new file mode 100644
index 000000000..b6d1f917e
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundaryError.css
@@ -0,0 +1,38 @@
+.container {
+ text-align: center;
+}
+
+.message {
+ margin: 50px 0;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.imageContainer {
+ display: flex;
+ justify-content: center;
+ flex: 0 0 auto;
+}
+
+.image {
+ height: 350px;
+}
+
+.details {
+ margin: 20px;
+ text-align: left;
+ white-space: pre-wrap;
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .image {
+ height: 250px;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .image {
+ height: 150px;
+ }
+}
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js
new file mode 100644
index 000000000..e0181db96
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundaryError.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './ErrorBoundaryError.css';
+
+function ErrorBoundaryError(props) {
+ const {
+ className,
+ messageClassName,
+ detailsClassName,
+ message,
+ error,
+ info
+ } = props;
+
+ return (
+
+
+ {message}
+
+
+
+
+
+
+
+ {
+ error &&
+
+ {error.toString()}
+
+ }
+
+
+ {info.componentStack}
+
+
+
+ );
+}
+
+ErrorBoundaryError.propTypes = {
+ className: PropTypes.string.isRequired,
+ messageClassName: PropTypes.string.isRequired,
+ detailsClassName: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ error: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired
+};
+
+ErrorBoundaryError.defaultProps = {
+ className: styles.container,
+ messageClassName: styles.message,
+ detailsClassName: styles.details,
+ message: 'There was an error loading this content'
+};
+
+export default ErrorBoundaryError;
diff --git a/frontend/src/Components/FieldSet.css b/frontend/src/Components/FieldSet.css
new file mode 100644
index 000000000..daf3bdf2e
--- /dev/null
+++ b/frontend/src/Components/FieldSet.css
@@ -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;
+}
diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js
new file mode 100644
index 000000000..76e68a934
--- /dev/null
+++ b/frontend/src/Components/FieldSet.js
@@ -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 (
+
+
+ {legend}
+
+ {children}
+
+ );
+ }
+
+}
+
+FieldSet.propTypes = {
+ legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ children: PropTypes.node
+};
+
+export default FieldSet;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css b/frontend/src/Components/FileBrowser/FileBrowserModal.css
new file mode 100644
index 000000000..30b936800
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css
@@ -0,0 +1,5 @@
+.modal {
+ composes: modal from 'Components/Modal/Modal.css';
+
+ height: 600px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js
new file mode 100644
index 000000000..6b58dbb8c
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js
@@ -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 (
+
+
+
+ );
+ }
+}
+
+FileBrowserModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FileBrowserModal;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css
new file mode 100644
index 000000000..9ae11f0bd
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css
@@ -0,0 +1,33 @@
+.modalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex-direction: column;
+}
+
+.mappedDrivesWarning {
+ composes: alert from 'Components/Alert.css';
+
+ margin: 0;
+ margin-bottom: 20px;
+}
+
+.faqLink {
+ color: $alertWarningColor;
+ font-weight: bold;
+}
+
+.pathInput {
+ composes: pathInputWrapper from 'Components/Form/PathInput.css';
+
+ flex: 0 0 auto;
+}
+
+.scroller {
+ margin-top: 20px;
+}
+
+.loading {
+ display: inline-block;
+ margin-right: auto;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
new file mode 100644
index 000000000..6c178f6c7
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
@@ -0,0 +1,253 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { kinds, scrollDirections } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 Scroller from 'Components/Scroller/Scroller';
+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, prevState) {
+ const {
+ currentPath
+ } = this.props;
+
+ if (
+ currentPath !== this.state.currentPath &&
+ currentPath !== prevState.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 {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ directories,
+ files,
+ isWindowsService,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const emptyParent = parent === '';
+
+ return (
+
+
+ File Browser
+
+
+
+ {
+ isWindowsService &&
+
+ Mapped network drives are not available when running as a Windows Service, see the FAQ for more information.
+
+ }
+
+
+
+
+ {
+ !!error &&
+ Error loading contents
+ }
+
+ {
+ isPopulated && !error &&
+
+
+ {
+ emptyParent &&
+
+ }
+
+ {
+ !emptyParent && parent &&
+
+ }
+
+ {
+ directories.map((directory) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ files.map((file) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+
+ Cancel
+
+
+
+ Ok
+
+
+
+ );
+ }
+}
+
+FileBrowserModalContent.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ parent: PropTypes.string,
+ currentPath: PropTypes.string.isRequired,
+ directories: PropTypes.arrayOf(PropTypes.object).isRequired,
+ files: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isWindowsService: PropTypes.bool.isRequired,
+ onFetchPaths: PropTypes.func.isRequired,
+ onClearPaths: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FileBrowserModalContent;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
new file mode 100644
index 000000000..fe577b896
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
@@ -0,0 +1,101 @@
+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 createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import FileBrowserModalContent from './FileBrowserModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.paths,
+ createSystemStatusSelector(),
+ (paths, systemStatus) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ currentPath,
+ directories,
+ files
+ } = paths;
+
+ const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ currentPath,
+ directories,
+ files,
+ paths: filteredPaths,
+ isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchPaths,
+ clearPaths
+};
+
+class FileBrowserModalContentConnector extends Component {
+
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchPaths({
+ path: this.props.value,
+ allowFoldersWithoutTrailingSlashes: true
+ });
+ }
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ this.props.fetchPaths({
+ path,
+ allowFoldersWithoutTrailingSlashes: true
+ });
+ }
+
+ onClearPaths = () => {
+ // this.props.clearPaths();
+ }
+
+ onModalClose = () => {
+ this.props.clearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+FileBrowserModalContentConnector.propTypes = {
+ value: PropTypes.string,
+ fetchPaths: PropTypes.func.isRequired,
+ clearPaths: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css b/frontend/src/Components/FileBrowser/FileBrowserRow.css
new file mode 100644
index 000000000..a9c34be6a
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css
@@ -0,0 +1,5 @@
+.type {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 32px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js
new file mode 100644
index 000000000..42ac30405
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.js
@@ -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 (
+
+
+
+
+
+ {name}
+
+ );
+ }
+
+}
+
+FileBrowserRow.propTypes = {
+ type: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default FileBrowserRow;
diff --git a/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js
new file mode 100644
index 000000000..eea574dd1
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: true, name: 'true' },
+ { id: false, name: 'false' }
+];
+
+function BoolFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default BoolFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css
new file mode 100644
index 000000000..fd56a4917
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css
@@ -0,0 +1,15 @@
+.container {
+ display: flex;
+}
+
+.numberInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ margin-right: 3px;
+}
+
+.selectInput {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ margin-left: 3px;
+}
diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js
new file mode 100644
index 000000000..f0c2d3626
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js
@@ -0,0 +1,171 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import isString from 'Utilities/String/isString';
+import { IN_LAST, IN_NEXT } from 'Helpers/Props/filterTypes';
+import NumberInput from 'Components/Form/NumberInput';
+import SelectInput from 'Components/Form/SelectInput';
+import TextInput from 'Components/Form/TextInput';
+import { NAME } from './FilterBuilderRowValue';
+import styles from './DateFilterBuilderRowValue.css';
+
+const timeOptions = [
+ { key: 'seconds', value: 'seconds' },
+ { key: 'minutes', value: 'minutes' },
+ { key: 'hours', value: 'hours' },
+ { key: 'days', value: 'days' },
+ { key: 'weeks', value: 'weeks' },
+ { key: 'months', value: 'months' }
+];
+
+function isInFilter(filterType) {
+ return filterType === IN_LAST || filterType === IN_NEXT;
+}
+
+class DateFilterBuilderRowValue extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ filterType,
+ filterValue,
+ onChange
+ } = this.props;
+
+ if (isInFilter(filterType) && isString(filterValue)) {
+ onChange({
+ name: NAME,
+ value: {
+ time: timeOptions[0].key,
+ value: null
+ }
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ filterType,
+ filterValue,
+ onChange
+ } = this.props;
+
+ if (prevProps.filterType === filterType) {
+ return;
+ }
+
+ if (isInFilter(filterType) && isString(filterValue)) {
+ onChange({
+ name: NAME,
+ value: {
+ time: timeOptions[0].key,
+ value: null
+ }
+ });
+
+ return;
+ }
+
+ if (!isInFilter(filterType) && !isString(filterValue)) {
+ onChange({
+ name: NAME,
+ value: ''
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onValueChange = ({ value }) => {
+ const {
+ filterValue,
+ onChange
+ } = this.props;
+
+ let newValue = value;
+
+ if (!isString(value)) {
+ newValue = {
+ time: filterValue.time,
+ value
+ };
+ }
+
+ onChange({
+ name: NAME,
+ value: newValue
+ });
+ }
+
+ onTimeChange = ({ value }) => {
+ const {
+ filterValue,
+ onChange
+ } = this.props;
+
+ onChange({
+ name: NAME,
+ value: {
+ time: value,
+ value: filterValue.value
+ }
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterType,
+ filterValue
+ } = this.props;
+
+ if (
+ (isInFilter(filterType) && isString(filterValue)) ||
+ (!isInFilter(filterType) && !isString(filterValue))
+ ) {
+ return null;
+ }
+
+ if (isInFilter(filterType)) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+DateFilterBuilderRowValue.propTypes = {
+ filterType: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+export default DateFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css
new file mode 100644
index 000000000..6cc8fab67
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css
@@ -0,0 +1,16 @@
+.labelContainer {
+ margin-bottom: 20px;
+}
+
+.label {
+ margin-bottom: 5px;
+ font-weight: bold;
+}
+
+.labelInputContainer {
+ width: 300px;
+}
+
+.rows {
+ margin-bottom: 100px;
+}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
new file mode 100644
index 000000000..ed3bc2409
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
@@ -0,0 +1,226 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+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 FilterBuilderRow from './FilterBuilderRow';
+import styles from './FilterBuilderModalContent.css';
+
+class FilterBuilderModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const filters = [...props.filters];
+
+ // Push an empty filter if there aren't any filters. FilterBuilderRow
+ // will handle initializing the filter.
+
+ if (!filters.length) {
+ filters.push({});
+ }
+
+ this.state = {
+ label: props.label,
+ filters,
+ labelErrors: []
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ customFilters,
+ isSaving,
+ saveError,
+ dispatchSetFilter,
+ onModalClose
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ if (id) {
+ dispatchSetFilter({ selectedFilterKey: id });
+ } else {
+ const last = customFilters[customFilters.length -1];
+ dispatchSetFilter({ selectedFilterKey: last.id });
+ }
+
+ onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onLabelChange = ({ value }) => {
+ this.setState({ label: value });
+ }
+
+ onFilterChange = (index, filter) => {
+ const filters = [...this.state.filters];
+ filters.splice(index, 1, filter);
+
+ this.setState({
+ filters
+ });
+ }
+
+ onAddFilterPress = () => {
+ const filters = [...this.state.filters];
+ filters.push({});
+
+ this.setState({
+ filters
+ });
+ }
+
+ onRemoveFilterPress = (index) => {
+ const filters = [...this.state.filters];
+ filters.splice(index, 1);
+
+ this.setState({
+ filters
+ });
+ }
+
+ onSaveFilterPress = () => {
+ const {
+ id,
+ customFilterType,
+ onSaveCustomFilterPress
+ } = this.props;
+
+ const {
+ label,
+ filters
+ } = this.state;
+
+ if (!label) {
+ this.setState({
+ labelErrors: [
+ {
+ message: 'Label is required'
+ }
+ ]
+ });
+
+ return;
+ }
+
+ onSaveCustomFilterPress({
+ id,
+ type: customFilterType,
+ label,
+ filters
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ sectionItems,
+ filterBuilderProps,
+ isSaving,
+ saveError,
+ onModalClose
+ } = this.props;
+
+ const {
+ label,
+ filters,
+ labelErrors
+ } = this.state;
+
+ return (
+
+
+ Custom Filter
+
+
+
+
+
+ Filters
+
+
+ {
+ filters.map((filter, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+FilterBuilderModalContent.propTypes = {
+ id: PropTypes.number,
+ label: PropTypes.string.isRequired,
+ customFilterType: PropTypes.string.isRequired,
+ sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ dispatchDeleteCustomFilter: PropTypes.func.isRequired,
+ onSaveCustomFilterPress: PropTypes.func.isRequired,
+ dispatchSetFilter: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FilterBuilderModalContent;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js
new file mode 100644
index 000000000..c94db9925
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js
@@ -0,0 +1,42 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { saveCustomFilter, deleteCustomFilter } from 'Store/Actions/customFilterActions';
+import FilterBuilderModalContent from './FilterBuilderModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { customFilters }) => customFilters,
+ (state, { id }) => id,
+ (state) => state.customFilters.isSaving,
+ (state) => state.customFilters.saveError,
+ (customFilters, id, isSaving, saveError) => {
+ if (id) {
+ const customFilter = customFilters.find((c) => c.id === id);
+
+ return {
+ id: customFilter.id,
+ label: customFilter.label,
+ filters: customFilter.filters,
+ customFilters,
+ isSaving,
+ saveError
+ };
+ }
+
+ return {
+ label: '',
+ filters: [],
+ customFilters,
+ isSaving,
+ saveError
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onSaveCustomFilterPress: saveCustomFilter,
+ dispatchDeleteCustomFilter: deleteCustomFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent);
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css
new file mode 100644
index 000000000..c5471b253
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css
@@ -0,0 +1,32 @@
+.filterRow {
+ display: flex;
+ margin-bottom: 5px;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.inputContainer {
+ flex: 0 1 200px;
+ margin-right: 10px;
+}
+
+.valueInputContainer {
+ flex: 0 1 300px;
+ margin-right: 10px;
+}
+
+.actionsContainer {
+ display: flex;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .filterRow {
+ display: block;
+ }
+
+ .inputContainer {
+ margin-bottom: 10px;
+ }
+}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
new file mode 100644
index 000000000..7c3ab83e1
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
@@ -0,0 +1,286 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
+import SelectInput from 'Components/Form/SelectInput';
+import IconButton from 'Components/Link/IconButton';
+import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
+import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
+import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
+import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
+import LanguageProfileFilterBuilderRowValueConnector from './LanguageProfileFilterBuilderRowValueConnector';
+import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
+import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
+import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
+import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
+import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
+import styles from './FilterBuilderRow.css';
+
+function getselectedFilterBuilderProp(filterBuilderProps, name) {
+ return filterBuilderProps.find((a) => {
+ return a.name === name;
+ });
+}
+
+function getFilterTypeOptions(filterBuilderProps, filterKey) {
+ const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey);
+
+ if (!selectedFilterBuilderProp) {
+ return [];
+ }
+
+ return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type];
+}
+
+function getDefaultFilterType(selectedFilterBuilderProp) {
+ return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key;
+}
+
+function getDefaultFilterValue(selectedFilterBuilderProp) {
+ if (selectedFilterBuilderProp.type === filterBuilderTypes.DATE) {
+ return '';
+ }
+
+ return [];
+}
+
+function getRowValueConnector(selectedFilterBuilderProp) {
+ if (!selectedFilterBuilderProp) {
+ return FilterBuilderRowValueConnector;
+ }
+
+ const valueType = selectedFilterBuilderProp.valueType;
+
+ switch (valueType) {
+ case filterBuilderValueTypes.BOOL:
+ return BoolFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.DATE:
+ return DateFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.INDEXER:
+ return IndexerFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.LANGUAGE_PROFILE:
+ return LanguageProfileFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.PROTOCOL:
+ return ProtocolFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.QUALITY:
+ return QualityFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.QUALITY_PROFILE:
+ return QualityProfileFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.SERIES_STATUS:
+ return SeriesStatusFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.TAG:
+ return TagFilterBuilderRowValueConnector;
+
+ default:
+ return FilterBuilderRowValueConnector;
+ }
+}
+
+class FilterBuilderRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ filterKey,
+ filterBuilderProps
+ } = props;
+
+ if (filterKey) {
+ const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey);
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+ }
+ }
+
+ componentDidMount() {
+ const {
+ index,
+ filterKey,
+ filterBuilderProps,
+ onFilterChange
+ } = this.props;
+
+ if (filterKey) {
+ const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey);
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+
+ return;
+ }
+
+ const selectedFilterBuilderProp = filterBuilderProps[0];
+
+ const filter = {
+ key: selectedFilterBuilderProp.name,
+ value: getDefaultFilterValue(selectedFilterBuilderProp),
+ type: getDefaultFilterType(selectedFilterBuilderProp)
+ };
+
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+ onFilterChange(index, filter);
+ }
+
+ //
+ // Listeners
+
+ onFilterKeyChange = ({ value: key }) => {
+ const {
+ index,
+ filterBuilderProps,
+ onFilterChange
+ } = this.props;
+
+ const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key);
+ const type = getDefaultFilterType(selectedFilterBuilderProp);
+
+ const filter = {
+ key,
+ value: getDefaultFilterValue(selectedFilterBuilderProp),
+ type
+ };
+
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+ onFilterChange(index, filter);
+ }
+
+ onFilterChange = ({ name, value }) => {
+ const {
+ index,
+ filterKey,
+ filterValue,
+ filterType,
+ onFilterChange
+ } = this.props;
+
+ const filter = {
+ key: filterKey,
+ value: filterValue,
+ type: filterType
+ };
+
+ filter[name] = value;
+
+ onFilterChange(index, filter);
+ }
+
+ onAddPress = () => {
+ const {
+ index,
+ onAddPress
+ } = this.props;
+
+ onAddPress(index);
+ }
+
+ onRemovePress = () => {
+ const {
+ index,
+ onRemovePress
+ } = this.props;
+
+ onRemovePress(index);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterKey,
+ filterType,
+ filterValue,
+ filterCount,
+ filterBuilderProps,
+ sectionItems
+ } = this.props;
+
+ const selectedFilterBuilderProp = this.selectedFilterBuilderProp;
+
+ const keyOptions = filterBuilderProps.map((availablePropFilter) => {
+ return {
+ key: availablePropFilter.name,
+ value: availablePropFilter.label
+ };
+ });
+
+ const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
+
+ return (
+
+
+ {
+ filterKey &&
+
+ }
+
+
+
+ {
+ filterType &&
+
+ }
+
+
+
+ {
+ filterValue != null && !!selectedFilterBuilderProp &&
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+FilterBuilderRow.propTypes = {
+ index: PropTypes.number.isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]),
+ filterType: PropTypes.string,
+ filterCount: PropTypes.number.isRequired,
+ filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onFilterChange: PropTypes.func.isRequired,
+ onAddPress: PropTypes.func.isRequired,
+ onRemovePress: PropTypes.func.isRequired
+};
+
+export default FilterBuilderRow;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
new file mode 100644
index 000000000..70c496620
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
@@ -0,0 +1,159 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import convertToBytes from 'Utilities/Number/convertToBytes';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props';
+import TagInput, { tagShape } from 'Components/Form/TagInput';
+import FilterBuilderRowValueTag from './FilterBuilderRowValueTag';
+
+export const NAME = 'value';
+
+function getTagDisplayValue(value, selectedFilterBuilderProp) {
+ if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
+ return formatBytes(value);
+ }
+
+ return value;
+}
+
+function getValue(input, selectedFilterBuilderProp) {
+ if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
+ const match = input.match(/^(\d+)([kmgt](i?b)?)$/i);
+
+ if (match && match.length > 1) {
+ const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i);
+
+ switch (unit.toLowerCase()) {
+ case 'k':
+ return convertToBytes(value, 1, true);
+ case 'm':
+ return convertToBytes(value, 2, true);
+ case 'g':
+ return convertToBytes(value, 3, true);
+ case 't':
+ return convertToBytes(value, 4, true);
+ case 'kb':
+ return convertToBytes(value, 1, true);
+ case 'mb':
+ return convertToBytes(value, 2, true);
+ case 'gb':
+ return convertToBytes(value, 3, true);
+ case 'tb':
+ return convertToBytes(value, 4, true);
+ case 'kib':
+ return convertToBytes(value, 1, true);
+ case 'mib':
+ return convertToBytes(value, 2, true);
+ case 'gib':
+ return convertToBytes(value, 3, true);
+ case 'tib':
+ return convertToBytes(value, 4, true);
+ default:
+ return parseInt(value);
+ }
+ }
+ }
+
+ if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) {
+ return parseInt(input);
+ }
+
+ return input;
+}
+
+class FilterBuilderRowValue extends Component {
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ filterValue,
+ selectedFilterBuilderProp,
+ onChange
+ } = this.props;
+
+ let value = tag.id;
+
+ if (value == null) {
+ value = getValue(tag.name, selectedFilterBuilderProp);
+ }
+
+ onChange({
+ name: NAME,
+ value: [...filterValue, value]
+ });
+ }
+
+ onTagDelete = ({ index }) => {
+ const {
+ filterValue,
+ onChange
+ } = this.props;
+
+ const value = filterValue.filter((v, i) => i !== index);
+
+ onChange({
+ name: NAME,
+ value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterValue,
+ selectedFilterBuilderProp,
+ tagList
+ } = this.props;
+
+ const hasItems = !!tagList.length;
+
+ const tags = filterValue.map((id) => {
+ if (hasItems) {
+ const tag = tagList.find((t) => t.id === id);
+
+ return {
+ id,
+ name: tag && tag.name
+ };
+ }
+
+ return {
+ id,
+ name: getTagDisplayValue(id, selectedFilterBuilderProp)
+ };
+ });
+
+ return (
+
+ );
+ }
+}
+
+FilterBuilderRowValue.propTypes = {
+ filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])).isRequired,
+ selectedFilterBuilderProp: PropTypes.object.isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+FilterBuilderRowValue.defaultProps = {
+ filterValue: []
+};
+
+export default FilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..ac74240e4
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
@@ -0,0 +1,55 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import sortByName from 'Utilities/Array/sortByName';
+import { filterBuilderTypes } from 'Helpers/Props';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createTagListSelector() {
+ return createSelector(
+ (state, { sectionItems }) => sectionItems,
+ (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp,
+ (sectionItems, selectedFilterBuilderProp) => {
+ if (
+ selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ||
+ selectedFilterBuilderProp.type === filterBuilderTypes.STRING
+ ) {
+ return [];
+ }
+
+ let items = [];
+
+ if (selectedFilterBuilderProp.optionsSelector) {
+ items = selectedFilterBuilderProp.optionsSelector(sectionItems);
+ } else {
+ items = sectionItems.reduce((acc, item) => {
+ const name = item[selectedFilterBuilderProp.name];
+
+ if (name) {
+ acc.push({
+ id: name,
+ name
+ });
+ }
+
+ return acc;
+ }, []).sort(sortByName);
+ }
+
+ return _.uniqBy(items, 'id');
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagListSelector(),
+ (tagList) => {
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css
new file mode 100644
index 000000000..1c4c5acf1
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css
@@ -0,0 +1,19 @@
+.tag {
+ &.isLastTag {
+ .or {
+ display: none;
+ }
+ }
+}
+
+.label {
+ composes: label from 'Components/Label.css';
+
+ border-style: none;
+ font-size: 13px;
+}
+
+.or {
+ margin: 0 3px;
+ color: $themeDarkColor;
+}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
new file mode 100644
index 000000000..573e05759
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import TagInputTag from 'Components/Form/TagInputTag';
+import styles from './FilterBuilderRowValueTag.css';
+
+function FilterBuilderRowValueTag(props) {
+ return (
+
+
+
+ {
+ !props.isLastTag &&
+
+ or
+
+ }
+
+ );
+}
+
+FilterBuilderRowValueTag.propTypes = {
+ isLastTag: PropTypes.bool.isRequired
+};
+
+export default FilterBuilderRowValueTag;
diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..968b26d2c
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexers } from 'Store/Actions/settingsActions';
+import { tagShape } from 'Components/Form/TagInput';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (qualityProfiles) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = qualityProfiles;
+
+ const tagList = items.map((item) => {
+ return {
+ id: item.id,
+ name: item.name
+ };
+ });
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchIndexers: fetchIndexers
+};
+
+class IndexerFilterBuilderRowValueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchIndexers();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+IndexerFilterBuilderRowValueConnector.propTypes = {
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ dispatchFetchIndexers: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector);
diff --git a/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..31b1e952a
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.languageProfiles,
+ (languageProfiles) => {
+ const tagList = languageProfiles.items.map((languageProfile) => {
+ const {
+ id,
+ name
+ } = languageProfile;
+
+ return {
+ id,
+ name
+ };
+ });
+
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js
new file mode 100644
index 000000000..ae63ae0eb
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: 'torrent', name: 'Torrent' },
+ { id: 'usenet', name: 'Usenet' }
+];
+
+function ProtocolFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default ProtocolFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..0290bcdcb
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js
@@ -0,0 +1,75 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import getQualities from 'Utilities/Quality/getQualities';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import { tagShape } from 'Components/Form/TagInput';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = qualityProfiles;
+
+ const tagList = getQualities(schema.items);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityProfileSchema: fetchQualityProfileSchema
+};
+
+class QualityFilterBuilderRowValueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+QualityFilterBuilderRowValueConnector.propTypes = {
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector);
diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..4a8b82283
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const tagList = qualityProfiles.items.map((qualityProfile) => {
+ const {
+ id,
+ name
+ } = qualityProfile;
+
+ return {
+ id,
+ name
+ };
+ });
+
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
new file mode 100644
index 000000000..50841a013
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: 'continuing', name: 'Continuing' },
+ { id: 'ended', name: 'Ended' }
+];
+
+function SeriesStatusFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default SeriesStatusFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..60e04c446
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagsSelector(),
+ (tagList) => {
+ return {
+ tagList: tagList.map((tag) => {
+ const {
+ id,
+ label: name
+ } = tag;
+
+ return {
+ id,
+ name
+ };
+ })
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css
new file mode 100644
index 000000000..7acb69dc7
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css
@@ -0,0 +1,17 @@
+.customFilter {
+ display: flex;
+ margin-bottom: 5px;
+ padding: 5px;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.label {
+ flex: 0 1 300px;
+}
+
+.actions {
+ flex: 0 0 60px;
+}
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
new file mode 100644
index 000000000..c9c326d78
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import styles from './CustomFilter.css';
+
+class CustomFilter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDeleting: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDeleting,
+ deleteError
+ } = this.props;
+
+ if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) {
+ this.setState({ isDeleting: false });
+ }
+ }
+
+ componentWillUnmount() {
+ const {
+ id,
+ selectedFilterKey,
+ dispatchSetFilter
+ } = this.props;
+
+ // Assume that delete and then unmounting means the delete was successful.
+ // Moving this check to a ancestor would be more accurate, but would have
+ // more boilerplate.
+ if (this.state.isDeleting && id === selectedFilterKey) {
+ dispatchSetFilter({ selectedFilterKey: 'all' });
+ }
+ }
+
+ //
+ // Listeners
+
+ onEditPress = () => {
+ const {
+ id,
+ onEditPress
+ } = this.props;
+
+ onEditPress(id);
+ }
+
+ onRemovePress = () => {
+ const {
+ id,
+ dispatchDeleteCustomFilter
+ } = this.props;
+
+ this.setState({ isDeleting: true }, () => {
+ dispatchDeleteCustomFilter({ id });
+ });
+
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ label
+ } = this.props;
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+CustomFilter.propTypes = {
+ id: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ dispatchSetFilter: PropTypes.func.isRequired,
+ onEditPress: PropTypes.func.isRequired,
+ dispatchDeleteCustomFilter: PropTypes.func.isRequired
+};
+
+export default CustomFilter;
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css
new file mode 100644
index 000000000..c391764dc
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css
@@ -0,0 +1,3 @@
+.addButtonContainer {
+ margin-top: 15px;
+}
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
new file mode 100644
index 000000000..1a7168fca
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from 'Components/Link/Button';
+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 CustomFilter from './CustomFilter';
+import styles from './CustomFiltersModalContent.css';
+
+function CustomFiltersModalContent(props) {
+ const {
+ selectedFilterKey,
+ customFilters,
+ isDeleting,
+ deleteError,
+ dispatchDeleteCustomFilter,
+ dispatchSetFilter,
+ onAddCustomFilter,
+ onEditCustomFilter,
+ onModalClose
+ } = props;
+
+ return (
+
+
+ Custom Filters
+
+
+
+ {
+ customFilters.map((customFilter, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ Add Custom Filter
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+CustomFiltersModalContent.propTypes = {
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ dispatchDeleteCustomFilter: PropTypes.func.isRequired,
+ dispatchSetFilter: PropTypes.func.isRequired,
+ onAddCustomFilter: PropTypes.func.isRequired,
+ onEditCustomFilter: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CustomFiltersModalContent;
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js
new file mode 100644
index 000000000..32425d766
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteCustomFilter } from 'Store/Actions/customFilterActions';
+import CustomFiltersModalContent from './CustomFiltersModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.customFilters.isDeleting,
+ (state) => state.customFilters.deleteError,
+ (isDeleting, deleteError) => {
+ return {
+ isDeleting,
+ deleteError
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchDeleteCustomFilter: deleteCustomFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent);
diff --git a/frontend/src/Components/Filter/FilterModal.js b/frontend/src/Components/Filter/FilterModal.js
new file mode 100644
index 000000000..750d1ed48
--- /dev/null
+++ b/frontend/src/Components/Filter/FilterModal.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector';
+import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector';
+
+class FilterModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ filterBuilder: !props.customFilters.length,
+ id: null
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddCustomFilter = () => {
+ this.setState({
+ filterBuilder: true
+ });
+ }
+
+ onEditCustomFilter = (id) => {
+ this.setState({
+ filterBuilder: true,
+ id
+ });
+ }
+
+ onModalClose = () => {
+ this.setState({
+ filterBuilder: false,
+ id: null
+ }, () => {
+ this.props.onModalClose();
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ ...otherProps
+ } = this.props;
+
+ const {
+ filterBuilder,
+ id
+ } = this.state;
+
+ return (
+
+ {
+ filterBuilder ?
+ :
+
+ }
+
+ );
+ }
+}
+
+FilterModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FilterModal;
diff --git a/frontend/src/Components/Form/AutoCompleteInput.css b/frontend/src/Components/Form/AutoCompleteInput.css
new file mode 100644
index 000000000..417a71437
--- /dev/null
+++ b/frontend/src/Components/Form/AutoCompleteInput.css
@@ -0,0 +1,58 @@
+.input {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.inputWrapper {
+ display: flex;
+}
+
+.inputContainer {
+ position: relative;
+ flex-grow: 1;
+}
+
+.container {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.inputContainerOpen {
+ .container {
+ 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;
+ }
+}
+
+.list {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.listItem {
+ padding: 0 16px;
+}
+
+.match {
+ font-weight: bold;
+}
+
+.highlighted {
+ background-color: $menuItemHoverBackgroundColor;
+}
diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js
new file mode 100644
index 000000000..740726b36
--- /dev/null
+++ b/frontend/src/Components/Form/AutoCompleteInput.js
@@ -0,0 +1,162 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import classNames from 'classnames';
+import jdu from 'jdu';
+import styles from './AutoCompleteInput.css';
+
+class AutoCompleteInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ suggestions: []
+ };
+ }
+
+ //
+ // Control
+
+ getSuggestionValue(item) {
+ return item;
+ }
+
+ renderSuggestion(item) {
+ return item;
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (event, { newValue }) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: newValue
+ });
+ }
+
+ onInputKeyDown = (event) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const { suggestions } = this.state;
+
+ if (
+ event.key === 'Tab' &&
+ suggestions.length &&
+ suggestions[0] !== this.props.value
+ ) {
+ event.preventDefault();
+
+ if (value) {
+ onChange({
+ name,
+ value: suggestions[0]
+ });
+ }
+ }
+ }
+
+ onInputBlur = () => {
+ this.setState({ suggestions: [] });
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const { values } = this.props;
+ const lowerCaseValue = jdu.replace(value).toLowerCase();
+
+ const filteredValues = values.filter((v) => {
+ return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
+ });
+
+ this.setState({ suggestions: filteredValues });
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.setState({ suggestions: [] });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ inputClassName,
+ name,
+ value,
+ placeholder,
+ hasError,
+ hasWarning
+ } = this.props;
+
+ const { suggestions } = this.state;
+
+ const inputProps = {
+ className: classNames(
+ inputClassName,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning,
+ ),
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onInputChange,
+ onKeyDown: this.onInputKeyDown,
+ onBlur: this.onInputBlur
+ };
+
+ const theme = {
+ container: styles.inputContainer,
+ containerOpen: styles.inputContainerOpen,
+ suggestionsContainer: styles.container,
+ suggestionsList: styles.list,
+ suggestion: styles.listItem,
+ suggestionHighlighted: styles.highlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+AutoCompleteInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ values: PropTypes.arrayOf(PropTypes.string).isRequired,
+ placeholder: PropTypes.string,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ onChange: PropTypes.func.isRequired
+};
+
+AutoCompleteInput.defaultProps = {
+ className: styles.inputWrapper,
+ inputClassName: styles.input,
+ value: ''
+};
+
+export default AutoCompleteInput;
diff --git a/frontend/src/Components/Form/CaptchaInput.css b/frontend/src/Components/Form/CaptchaInput.css
new file mode 100644
index 000000000..e7cd1dc4e
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js
new file mode 100644
index 000000000..e1a5df458
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.js
@@ -0,0 +1,84 @@
+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 (
+
+
+
+
+
+
+
+
+
+ {
+ !!siteKey && !!secretToken &&
+
+
+
+ }
+
+ );
+}
+
+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;
diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js
new file mode 100644
index 000000000..17b875c88
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInputConnector.js
@@ -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 (
+
+ );
+ }
+}
+
+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);
diff --git a/frontend/src/Components/Form/CheckInput.css b/frontend/src/Components/Form/CheckInput.css
new file mode 100644
index 000000000..5c35e5d2f
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js
new file mode 100644
index 000000000..134290111
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.js
@@ -0,0 +1,191 @@
+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) => {
+ if (this.props.isDisabled) {
+ return;
+ }
+
+ 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 (
+
+
+
+
+
+ {
+ isChecked &&
+
+ }
+
+ {
+ isIndeterminate &&
+
+ }
+
+
+ {
+ helpText &&
+
+ }
+
+ {
+ !helpText && helpTextWarning &&
+
+ }
+
+
+ );
+ }
+}
+
+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;
diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css
new file mode 100644
index 000000000..0c518e98e
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInput.css
@@ -0,0 +1,8 @@
+.deviceInputWrapper {
+ display: flex;
+}
+
+.inputContainer {
+ composes: inputContainer from './TagInput.css';
+ composes: hasButton from 'Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js
new file mode 100644
index 000000000..a38648e1a
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInput.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputButton from './FormInputButton';
+import TagInput, { tagShape } from './TagInput';
+import styles from './DeviceInput.css';
+
+class DeviceInput extends Component {
+
+ onTagAdd = (device) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ // New tags won't have an ID, only a name.
+ const deviceId = device.id || device.name;
+
+ onChange({
+ name,
+ value: [...value, deviceId]
+ });
+ }
+
+ onTagDelete = ({ index }) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ items,
+ selectedDevices,
+ hasError,
+ hasWarning,
+ isFetching,
+ onRefreshPress
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DeviceInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
+ items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired
+};
+
+DeviceInput.defaultProps = {
+ className: styles.deviceInputWrapper,
+ inputClassName: styles.input
+};
+
+export default DeviceInput;
diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js
new file mode 100644
index 000000000..d53372b35
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInputConnector.js
@@ -0,0 +1,99 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDevices, clearDevices } from 'Store/Actions/deviceActions';
+import DeviceInput from './DeviceInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ (state) => state.devices,
+ (value, devices) => {
+
+ return {
+ ...devices,
+ selectedDevices: value.map((valueDevice) => {
+ // Disable equality ESLint rule so we don't need to worry about
+ // a type mismatch between the value items and the device ID.
+ // eslint-disable-next-line eqeqeq
+ const device = devices.items.find((d) => d.id == valueDevice);
+
+ if (device) {
+ return {
+ id: device.id,
+ name: `${device.name} (${device.id})`
+ };
+ }
+
+ return {
+ id: valueDevice,
+ name: `Unknown (${valueDevice})`
+ };
+ })
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchDevices: fetchDevices,
+ dispatchClearDevices: clearDevices
+};
+
+class DeviceInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ this._populate();
+ }
+
+ componentWillUnmount = () => {
+ // this.props.dispatchClearDevices();
+ }
+
+ //
+ // Control
+
+ _populate() {
+ const {
+ provider,
+ providerData,
+ dispatchFetchDevices
+ } = this.props;
+
+ dispatchFetchDevices({ provider, providerData });
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this._populate();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DeviceInputConnector.propTypes = {
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ name: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ dispatchFetchDevices: PropTypes.func.isRequired,
+ dispatchClearDevices: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css
new file mode 100644
index 000000000..568e35f40
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css
@@ -0,0 +1,79 @@
+.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;
+}
+
+.dropdownArrowContainerDisabled {
+ composes: dropdownArrowContainer;
+
+ color: $disabledInputColor;
+}
+
+.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;
+}
+
+.optionsModalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ padding: 10px 0;
+}
+
+.optionsModalScroller {
+ composes: scroller from 'Components/Scroller/Scroller.css';
+
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
new file mode 100644
index 000000000..a127feaed
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -0,0 +1,411 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { icons, scrollDirections } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import Measure from 'Components/Measure';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import Scroller from 'Components/Scroller/Scroller';
+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 (
+
+
+
+
+
+ {selectedOption ? selectedOption.value : null}
+
+
+
+
+
+
+
+
+ {
+ isOpen && !isMobile &&
+
+
+ {
+ values.map((v, index) => {
+ return (
+
+ {v.value}
+
+ );
+ })
+ }
+
+
+ }
+
+
+ {
+ isMobile &&
+
+
+
+ {
+ values.map((v, index) => {
+ return (
+
+ {v.value}
+
+ );
+ })
+ }
+
+
+
+ }
+
+ );
+ }
+}
+
+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;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css
new file mode 100644
index 000000000..2b96de47f
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css
@@ -0,0 +1,41 @@
+.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;
+}
+
+.isHidden {
+ display: none;
+}
+
+.isMobile {
+ height: 50px;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-child {
+ border: none;
+ }
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
new file mode 100644
index 000000000..e1b410c28
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js
@@ -0,0 +1,81 @@
+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,
+ isHidden,
+ isMobile,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ {
+ isMobile &&
+
+
+
+ }
+
+ );
+ }
+}
+
+EnhancedSelectInputOption.propTypes = {
+ className: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isHidden: PropTypes.bool.isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ onSelect: PropTypes.func.isRequired
+};
+
+EnhancedSelectInputOption.defaultProps = {
+ className: styles.option,
+ isDisabled: false,
+ isHidden: false
+};
+
+export default EnhancedSelectInputOption;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
new file mode 100644
index 000000000..6b8b73af9
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
@@ -0,0 +1,7 @@
+.selectedValue {
+ flex: 1 1 auto;
+}
+
+.isDisabled {
+ color: $disabledInputColor;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
new file mode 100644
index 000000000..c40ee93c1
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './EnhancedSelectInputSelectedValue.css';
+
+function EnhancedSelectInputSelectedValue(props) {
+ const {
+ className,
+ children,
+ isDisabled
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+EnhancedSelectInputSelectedValue.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ isDisabled: PropTypes.bool.isRequired
+};
+
+EnhancedSelectInputSelectedValue.defaultProps = {
+ className: styles.selectedValue,
+ isDisabled: false
+};
+
+export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js
new file mode 100644
index 000000000..9a605297a
--- /dev/null
+++ b/frontend/src/Components/Form/Form.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+
+function Form({ children, validationErrors, validationWarnings, ...otherProps }) {
+ return (
+
+
+ {
+ validationErrors.map((error, index) => {
+ return (
+
+ {error.errorMessage}
+
+ );
+ })
+ }
+
+ {
+ validationWarnings.map((warning, index) => {
+ return (
+
+ {warning.errorMessage}
+
+ );
+ })
+ }
+
+
+ {children}
+
+ );
+}
+
+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;
diff --git a/frontend/src/Components/Form/FormGroup.css b/frontend/src/Components/Form/FormGroup.css
new file mode 100644
index 000000000..ddce8863b
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.css
@@ -0,0 +1,28 @@
+.group {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+/* Sizes */
+
+.extraSmall {
+ max-width: $formGroupExtraSmallWidth;
+}
+
+.small {
+ max-width: $formGroupSmallWidth;
+}
+
+.medium {
+ max-width: $formGroupMediumWidth;
+}
+
+.large {
+ max-width: $formGroupLargeWidth;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .group {
+ display: block;
+ }
+}
diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js
new file mode 100644
index 000000000..d2e04c350
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.js
@@ -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 (
+
+ {
+ map(children, (child) => {
+ return React.cloneElement(child, childProps);
+ })
+ }
+
+ );
+}
+
+FormGroup.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ size: PropTypes.oneOf(sizes.all).isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormGroup.defaultProps = {
+ className: styles.group,
+ size: sizes.SMALL,
+ advancedSettings: false,
+ isAdvanced: false
+};
+
+export default FormGroup;
diff --git a/frontend/src/Components/Form/FormInputButton.css b/frontend/src/Components/Form/FormInputButton.css
new file mode 100644
index 000000000..27a1923be
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js
new file mode 100644
index 000000000..4b6491663
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.js
@@ -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 (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+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;
diff --git a/frontend/src/Components/Form/FormInputGroup.css b/frontend/src/Components/Form/FormInputGroup.css
new file mode 100644
index 000000000..acdeb772f
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.css
@@ -0,0 +1,49 @@
+.inputGroupContainer {
+ flex: 1 1 auto;
+}
+
+.inputGroup {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+}
+
+.inputContainer {
+ position: relative;
+ flex: 1 1 auto;
+}
+
+.inputUnit {
+ position: absolute;
+ top: 0;
+ right: 20px;
+ margin-top: 7px;
+ width: 75px;
+ color: #c6c6c6;
+ text-align: right;
+ pointer-events: none;
+ user-select: none;
+}
+
+.inputUnitNumber {
+ composes: inputUnit;
+
+ right: 40px;
+}
+
+.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;
+}
diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js
new file mode 100644
index 000000000..7abb297d1
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -0,0 +1,261 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import AutoCompleteInput from './AutoCompleteInput';
+import CaptchaInputConnector from './CaptchaInputConnector';
+import CheckInput from './CheckInput';
+import DeviceInputConnector from './DeviceInputConnector';
+import KeyValueListInput from './KeyValueListInput';
+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.AUTO_COMPLETE:
+ return AutoCompleteInput;
+
+ case inputTypes.CAPTCHA:
+ return CaptchaInputConnector;
+
+ case inputTypes.CHECK:
+ return CheckInput;
+
+ case inputTypes.DEVICE:
+ return DeviceInputConnector;
+
+ case inputTypes.KEY_VALUE_LIST:
+ return KeyValueListInput;
+
+ 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,
+ unit,
+ 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 (
+
+
+
+
+
+ {
+ unit &&
+
+ {unit}
+
+ }
+
+
+ {
+ buttonsArray.map((button, index) => {
+ return React.cloneElement(
+ button,
+ {
+ isLastButton: index === lastButtonIndex
+ }
+ );
+ })
+ }
+
+ {/*
+ {
+ pending &&
+
+ }
+
*/}
+
+
+ {
+ !checkInput && helpText &&
+
+ }
+
+ {
+ !checkInput && helpTexts &&
+
+ {
+ helpTexts.map((text, index) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !checkInput && helpTextWarning &&
+
+ }
+
+ {
+ helpLink &&
+
+ More Info
+
+ }
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+FormInputGroup.propTypes = {
+ className: PropTypes.string.isRequired,
+ containerClassName: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ unit: PropTypes.string,
+ 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;
diff --git a/frontend/src/Components/Form/FormInputHelpText.css b/frontend/src/Components/Form/FormInputHelpText.css
new file mode 100644
index 000000000..c760d957c
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/FormInputHelpText.js b/frontend/src/Components/Form/FormInputHelpText.js
new file mode 100644
index 000000000..d9195568b
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.js
@@ -0,0 +1,63 @@
+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 (
+
+ {text}
+
+ {
+ !!link &&
+
+
+
+ }
+
+ );
+}
+
+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;
diff --git a/frontend/src/Components/Form/FormLabel.css b/frontend/src/Components/Form/FormLabel.css
new file mode 100644
index 000000000..236f4aab0
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.css
@@ -0,0 +1,29 @@
+.label {
+ display: flex;
+ justify-content: flex-end;
+ 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;
+ }
+}
+
+.small {
+ flex: 0 0 $formLabelSmallWidth;
+}
+
+.large {
+ flex: 0 0 $formLabelLargeWidth;
+}
diff --git a/frontend/src/Components/Form/FormLabel.js b/frontend/src/Components/Form/FormLabel.js
new file mode 100644
index 000000000..da7a443e3
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { sizes } from 'Helpers/Props';
+import styles from './FormLabel.css';
+
+function FormLabel({
+ children,
+ className,
+ errorClassName,
+ size,
+ name,
+ hasError,
+ isAdvanced,
+ ...otherProps
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+FormLabel.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+ errorClassName: PropTypes.string,
+ size: PropTypes.oneOf(sizes.all),
+ name: PropTypes.string,
+ hasError: PropTypes.bool,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormLabel.defaultProps = {
+ className: styles.label,
+ errorClassName: styles.hasError,
+ isAdvanced: false,
+ size: sizes.LARGE
+};
+
+export default FormLabel;
diff --git a/frontend/src/Components/Form/Input.css b/frontend/src/Components/Form/Input.css
new file mode 100644
index 000000000..e9ca23d8f
--- /dev/null
+++ b/frontend/src/Components/Form/Input.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/KeyValueListInput.css b/frontend/src/Components/Form/KeyValueListInput.css
new file mode 100644
index 000000000..59be3a4d7
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.css
@@ -0,0 +1,21 @@
+.inputContainer {
+ composes: input from 'Components/Form/Input.css';
+
+ position: relative;
+ min-height: 35px;
+ height: auto;
+
+ &.isFocused {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js
new file mode 100644
index 000000000..a52c76f70
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.js
@@ -0,0 +1,152 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import KeyValueListInputItem from './KeyValueListInputItem';
+import styles from './KeyValueListInput.css';
+
+class KeyValueListInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isFocused: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onItemChange = (index, itemValue) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = [...value];
+
+ if (index == null) {
+ newValue.push(itemValue);
+ } else {
+ newValue.splice(index, 1, itemValue);
+ }
+
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ onRemoveItem = (index) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = [...value];
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ onFocus = () => {
+ this.setState({
+ isFocused: true
+ });
+ }
+
+ onBlur = () => {
+ this.setState({
+ isFocused: false
+ });
+
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = value.reduce((acc, v) => {
+ if (v.key || v.value) {
+ acc.push(v);
+ }
+
+ return acc;
+ }, []);
+
+ if (newValue.length !== value.length) {
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ value,
+ keyPlaceholder,
+ valuePlaceholder
+ } = this.props;
+
+ const { isFocused } = this.state;
+
+ return (
+
+ {
+ [...value, { key: '', value: '' }].map((v, index) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+KeyValueListInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.object).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ keyPlaceholder: PropTypes.string,
+ valuePlaceholder: PropTypes.string,
+ onChange: PropTypes.func.isRequired
+};
+
+KeyValueListInput.defaultProps = {
+ className: styles.inputContainer,
+ value: []
+};
+
+export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css
new file mode 100644
index 000000000..f77ea3470
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css
@@ -0,0 +1,14 @@
+.itemContainer {
+ display: flex;
+ margin-bottom: 3px;
+ border-bottom: 1px solid $inputBorderColor;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.keyInput,
+.valueInput {
+ border: none;
+}
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js
new file mode 100644
index 000000000..4e465f3a9
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import TextInput from './TextInput';
+import styles from './KeyValueListInputItem.css';
+
+class KeyValueListInputItem extends Component {
+
+ //
+ // Listeners
+
+ onKeyChange = ({ value: keyValue }) => {
+ const {
+ index,
+ value,
+ onChange
+ } = this.props;
+
+ onChange(index, { key: keyValue, value });
+ }
+
+ onValueChange = ({ value }) => {
+ // TODO: Validate here or validate at a lower level component
+
+ const {
+ index,
+ keyValue,
+ onChange
+ } = this.props;
+
+ onChange(index, { key: keyValue, value });
+ }
+
+ onRemovePress = () => {
+ const {
+ index,
+ onRemove
+ } = this.props;
+
+ onRemove(index);
+ }
+
+ onFocus = () => {
+ this.props.onFocus();
+ }
+
+ onBlur = () => {
+ this.props.onBlur();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ keyValue,
+ value,
+ keyPlaceholder,
+ valuePlaceholder,
+ isNew
+ } = this.props;
+
+ return (
+
+
+
+
+
+ {
+ !isNew &&
+
+ }
+
+ );
+ }
+}
+
+KeyValueListInputItem.propTypes = {
+ index: PropTypes.number,
+ keyValue: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ keyPlaceholder: PropTypes.string.isRequired,
+ valuePlaceholder: PropTypes.string.isRequired,
+ isNew: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onRemove: PropTypes.func.isRequired,
+ onFocus: PropTypes.func.isRequired,
+ onBlur: PropTypes.func.isRequired
+};
+
+KeyValueListInputItem.defaultProps = {
+ keyPlaceholder: 'Key',
+ valuePlaceholder: 'Value'
+};
+
+export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/LanguageProfileSelectInputConnector.js b/frontend/src/Components/Form/LanguageProfileSelectInputConnector.js
new file mode 100644
index 000000000..970400a64
--- /dev/null
+++ b/frontend/src/Components/Form/LanguageProfileSelectInputConnector.js
@@ -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 (
+
+ );
+ }
+}
+
+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);
diff --git a/frontend/src/Components/Form/MonitorEpisodesSelectInput.js b/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
new file mode 100644
index 000000000..f26693e64
--- /dev/null
+++ b/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import monitorOptions from 'Utilities/Series/monitorOptions';
+import SelectInput from './SelectInput';
+
+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 (
+
+ );
+}
+
+MonitorEpisodesSelectInput.propTypes = {
+ includeNoChange: PropTypes.bool.isRequired,
+ includeMixed: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+MonitorEpisodesSelectInput.defaultProps = {
+ includeNoChange: false,
+ includeMixed: false
+};
+
+export default MonitorEpisodesSelectInput;
diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js
new file mode 100644
index 000000000..20b6fd0a1
--- /dev/null
+++ b/frontend/src/Components/Form/NumberInput.js
@@ -0,0 +1,81 @@
+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
+ });
+ }
+
+ onBlur = () => {
+ const {
+ name,
+ value,
+ min,
+ max,
+ onChange
+ } = this.props;
+
+ let newValue = value;
+
+ if (min != null && newValue != null && newValue < min) {
+ newValue = min;
+ } else if (max != null && newValue != null && newValue > max) {
+ newValue = max;
+ }
+
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+NumberInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.number,
+ min: PropTypes.number,
+ max: PropTypes.number,
+ isFloat: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+NumberInput.defaultProps = {
+ value: null,
+ isFloat: false
+};
+
+export default NumberInput;
diff --git a/frontend/src/Components/Form/OAuthInput.js b/frontend/src/Components/Form/OAuthInput.js
new file mode 100644
index 000000000..00825b6ba
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInput.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+
+function OAuthInput(props) {
+ const {
+ label,
+ authorizing,
+ error,
+ onPress
+ } = props;
+
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+OAuthInput.propTypes = {
+ label: PropTypes.string.isRequired,
+ authorizing: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ onPress: PropTypes.func.isRequired
+};
+
+OAuthInput.defaultProps = {
+ label: 'Start OAuth'
+};
+
+export default OAuthInput;
diff --git a/frontend/src/Components/Form/OAuthInputConnector.js b/frontend/src/Components/Form/OAuthInputConnector.js
new file mode 100644
index 000000000..7568aae7a
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInputConnector.js
@@ -0,0 +1,89 @@
+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 {
+ result,
+ onChange
+ } = this.props;
+
+ if (!result || result === prevProps.result) {
+ return;
+ }
+
+ Object.keys(result).forEach((key) => {
+ onChange({ name: key, value: result[key] });
+ });
+ }
+
+ componentWillUnmount = () => {
+ this.props.resetOAuth();
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ provider,
+ providerData,
+ section
+ } = this.props;
+
+ this.props.startOAuth({
+ name,
+ provider,
+ providerData,
+ section
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OAuthInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ result: PropTypes.object,
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ section: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ startOAuth: PropTypes.func.isRequired,
+ resetOAuth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);
diff --git a/frontend/src/Components/Form/PasswordInput.css b/frontend/src/Components/Form/PasswordInput.css
new file mode 100644
index 000000000..fca96bea9
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.css
@@ -0,0 +1,5 @@
+.input {
+ composes: input from 'Components/Form/TextInput.css';
+
+ font-family: $passwordFamily;
+}
diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js
new file mode 100644
index 000000000..adb1e7c5a
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import TextInput from './TextInput';
+import styles from './PasswordInput.css';
+
+function PasswordInput(props) {
+ return (
+
+ );
+}
+
+PasswordInput.propTypes = {
+ className: PropTypes.string.isRequired
+};
+
+PasswordInput.defaultProps = {
+ className: styles.input
+};
+
+export default PasswordInput;
diff --git a/frontend/src/Components/Form/PathInput.css b/frontend/src/Components/Form/PathInput.css
new file mode 100644
index 000000000..ce9fd8ebe
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.css
@@ -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 {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.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: $menuItemHoverBackgroundColor;
+}
+
+.fileBrowserButton {
+ composes: button from './FormInputButton.css';
+
+ height: 35px;
+}
diff --git a/frontend/src/Components/Form/PathInput.js b/frontend/src/Components/Form/PathInput.js
new file mode 100644
index 000000000..b62ba0555
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.js
@@ -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 (
+ {path}
+ );
+ }
+
+ return (
+
+
+ {path.substr(0, lastSeparatorIndex)}
+
+ {path.substr(lastSeparatorIndex)}
+
+ );
+ }
+
+ //
+ // 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];
+
+ if (path) {
+ this.props.onChange({
+ name: this.props.name,
+ value: path.path
+ });
+
+ if (path.type !== 'file') {
+ this.props.onFetchPaths(path.path);
+ }
+ }
+ }
+ }
+
+ onInputBlur = () => {
+ this.props.onClearPaths();
+ }
+
+ 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.
+ }
+
+ 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 (
+
+
+
+ {
+ hasFileBrowser &&
+
+
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+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;
diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js
new file mode 100644
index 000000000..4916daec8
--- /dev/null
+++ b/frontend/src/Components/Form/PathInputConnector.js
@@ -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 (
+
+ );
+ }
+}
+
+PathInputConnector.propTypes = {
+ fetchPaths: PropTypes.func.isRequired,
+ clearPaths: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
new file mode 100644
index 000000000..98922dae4
--- /dev/null
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -0,0 +1,120 @@
+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) {
+ switch (type) {
+ case 'captcha':
+ return inputTypes.CAPTCHA;
+ case 'checkbox':
+ return inputTypes.CHECK;
+ case 'device':
+ return inputTypes.DEVICE;
+ case 'password':
+ return inputTypes.PASSWORD;
+ case 'number':
+ return inputTypes.NUMBER;
+ case 'path':
+ return inputTypes.PATH;
+ case 'select':
+ return inputTypes.SELECT;
+ case 'tag':
+ return inputTypes.TEXT_TAG;
+ 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 (
+
+ {label}
+
+
+
+ );
+}
+
+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;
diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
new file mode 100644
index 000000000..16e0e46f2
--- /dev/null
+++ b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
@@ -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 (
+
+ );
+ }
+}
+
+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);
diff --git a/frontend/src/Components/Form/RootFolderSelectInput.js b/frontend/src/Components/Form/RootFolderSelectInput.js
new file mode 100644
index 000000000..08d88e5f1
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInput.js
@@ -0,0 +1,109 @@
+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,
+ isSaving,
+ saveError,
+ onChange
+ } = this.props;
+
+ const newRootFolderPath = this.state.newRootFolderPath;
+
+ if (
+ prevProps.isSaving &&
+ !isSaving &&
+ !saveError &&
+ 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 (
+
+
+
+
+
+ );
+ }
+}
+
+RootFolderSelectInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onNewRootFolderSelect: PropTypes.func.isRequired
+};
+
+RootFolderSelectInput.defaultProps = {
+ includeNoChange: false
+};
+
+export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
new file mode 100644
index 000000000..b3dfcbd20
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
@@ -0,0 +1,137 @@
+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,
+ isHidden: 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
+
+ componentWillMount() {
+ const {
+ value,
+ values,
+ onChange
+ } = this.props;
+
+ if (value == null && values[0].key === '') {
+ onChange({ name, value: '' });
+ }
+ }
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ values,
+ onChange
+ } = this.props;
+
+ if (!value || !_.some(values, (v) => v.key === 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 (
+
+ );
+ }
+}
+
+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);
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/RootFolderSelectInputOption.css
new file mode 100644
index 000000000..d8b44fcad
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.css
@@ -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: $darkGray;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js
new file mode 100644
index 000000000..a4db9cd82
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js
@@ -0,0 +1,45 @@
+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 (
+
+
+
{value}
+
+ {
+ freeSpace != null &&
+
+ {formatBytes(freeSpace)} Free
+
+ }
+
+
+ );
+}
+
+RootFolderSelectInputOption.propTypes = {
+ value: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number,
+ isMobile: PropTypes.bool.isRequired
+};
+
+export default RootFolderSelectInputOption;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
new file mode 100644
index 000000000..0a8fa6ffe
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
@@ -0,0 +1,22 @@
+.selectedValue {
+ composes: selectedValue from './EnhancedSelectInputSelectedValue.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.path {
+ @add-mixin truncate;
+
+ flex: 1 0 0;
+}
+
+.freeSpace {
+ flex: 0 0 auto;
+ margin-left: 15px;
+ color: $gray;
+ text-align: right;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
new file mode 100644
index 000000000..ffd769254
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
@@ -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 (
+
+
+ {value}
+
+
+ {
+ freeSpace != null && includeFreeSpace &&
+
+ {formatBytes(freeSpace)} Free
+
+ }
+
+ );
+}
+
+RootFolderSelectInputSelectedValue.propTypes = {
+ value: PropTypes.string,
+ freeSpace: PropTypes.number,
+ includeFreeSpace: PropTypes.bool.isRequired
+};
+
+RootFolderSelectInputSelectedValue.defaultProps = {
+ includeFreeSpace: true
+};
+
+export default RootFolderSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/SelectInput.css b/frontend/src/Components/Form/SelectInput.css
new file mode 100644
index 000000000..5f1c10e83
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js
new file mode 100644
index 000000000..113d50a09
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.js
@@ -0,0 +1,95 @@
+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,
+ autoFocus,
+ onBlur
+ } = this.props;
+
+ return (
+
+ {
+ values.map((option) => {
+ const {
+ key,
+ value: optionValue,
+ ...otherOptionProps
+ } = option;
+
+ return (
+
+ {optionValue}
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+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,
+ autoFocus: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onBlur: PropTypes.func
+};
+
+SelectInput.defaultProps = {
+ className: styles.select,
+ disabledClassName: styles.isDisabled,
+ isDisabled: false,
+ autoFocus: false
+};
+
+export default SelectInput;
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.js b/frontend/src/Components/Form/SeriesTypeSelectInput.js
new file mode 100644
index 000000000..7b0d0056d
--- /dev/null
+++ b/frontend/src/Components/Form/SeriesTypeSelectInput.js
@@ -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 (
+
+ );
+}
+
+SeriesTypeSelectInput.propTypes = {
+ includeNoChange: PropTypes.bool.isRequired,
+ includeMixed: PropTypes.bool.isRequired
+};
+
+SeriesTypeSelectInput.defaultProps = {
+ includeNoChange: false,
+ includeMixed: false
+};
+
+export default SeriesTypeSelectInput;
diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/TagInput.css
new file mode 100644
index 000000000..e22109368
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.css
@@ -0,0 +1,78 @@
+.inputContainer {
+ composes: input from 'Components/Form/Input.css';
+
+ position: relative;
+ padding: 0;
+ min-height: 35px;
+ height: auto;
+
+ &.isFocused {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.tags {
+ flex: 0 0 auto;
+ max-width: 100%;
+}
+
+.input {
+ flex: 1 1 0%;
+ margin-left: 3px;
+ min-width: 20%;
+ max-width: 100%;
+ width: 0%;
+ height: 21px;
+ border: none;
+}
+
+.suggestionsContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.containerOpen {
+ .suggestionsContainer {
+ position: absolute;
+ right: -1px;
+ left: -1px;
+ z-index: 1;
+ overflow-y: auto;
+ margin-top: 1px;
+ max-height: 110px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ }
+}
+
+.suggestionsList {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.suggestion {
+ padding: 0 16px;
+ cursor: default;
+
+ &:hover {
+ background-color: $menuItemHoverBackgroundColor;
+ }
+}
+
+.suggestionHighlighted {
+ background-color: $menuItemHoverBackgroundColor;
+}
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
new file mode 100644
index 000000000..81682f842
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.js
@@ -0,0 +1,303 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import TagInputInput from './TagInputInput';
+import TagInputTag from './TagInputTag';
+import styles from './TagInput.css';
+
+function getTag(value, selectedIndex, suggestions, allowNew) {
+ if (selectedIndex == null && value) {
+ const existingTag = _.find(suggestions, { name: value });
+
+ if (existingTag) {
+ return existingTag;
+ } else if (allowNew) {
+ return { name: value };
+ }
+ } else if (selectedIndex != null) {
+ return suggestions[selectedIndex];
+ }
+}
+
+class TagInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ value: '',
+ suggestions: [],
+ isFocused: false
+ };
+
+ this._autosuggestRef = null;
+ }
+
+ componentWillUnmount() {
+ this.addTag.cancel();
+ }
+
+ //
+ // Control
+
+ _setAutosuggestRef = (ref) => {
+ this._autosuggestRef = ref;
+ }
+
+ getSuggestionValue({ name }) {
+ return name;
+ }
+
+ shouldRenderSuggestions = (value) => {
+ return value.length >= this.props.minQueryLength;
+ }
+
+ renderSuggestion({ name }) {
+ return name;
+ }
+
+ addTag = _.debounce((tag) => {
+ this.props.onTagAdd(tag);
+
+ this.setState({
+ value: '',
+ suggestions: []
+ });
+ }, 250, { leading: true, trailing: false })
+
+ //
+ // Listeners
+
+ onInputContainerPress = () => {
+ this._autosuggestRef.input.focus();
+ }
+
+ onInputChange = (event, { newValue, method }) => {
+ const value = _.isObject(newValue) ? newValue.name : newValue;
+
+ if (method === 'type') {
+ this.setState({ value });
+ }
+ }
+
+ onInputKeyDown = (event) => {
+ const {
+ tags,
+ allowNew,
+ delimiters,
+ onTagDelete
+ } = this.props;
+
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const keyCode = event.keyCode;
+
+ if (keyCode === 8 && !value.length) {
+ const index = tags.length - 1;
+
+ if (index >= 0) {
+ onTagDelete({ index, id: tags[index].id });
+ }
+
+ setTimeout(() => {
+ this.onSuggestionsFetchRequested({ value: '' });
+ });
+
+ event.preventDefault();
+ }
+
+ if (delimiters.includes(keyCode)) {
+ const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ this.addTag(tag);
+ event.preventDefault();
+ }
+ }
+ }
+
+ onInputFocus = () => {
+ this.setState({ isFocused: true });
+ }
+
+ onInputBlur = () => {
+ this.setState({ isFocused: false });
+
+ if (!this._autosuggestRef) {
+ return;
+ }
+
+ const {
+ allowNew
+ } = this.props;
+
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ this.addTag(tag);
+ }
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const lowerCaseValue = value.toLowerCase();
+
+ const {
+ tags,
+ tagList
+ } = this.props;
+
+ const suggestions = tagList.filter((tag) => {
+ return (
+ tag.name.toLowerCase().includes(lowerCaseValue) &&
+ !tags.some((t) => t.id === tag.id));
+ });
+
+ this.setState({ suggestions });
+ }
+
+ 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.
+ }
+
+ onSuggestionSelected = (event, { suggestion }) => {
+ this.addTag(suggestion);
+ }
+
+ //
+ // Render
+
+ renderInputComponent = (inputProps) => {
+ const {
+ tags,
+ kind,
+ tagComponent,
+ onTagDelete
+ } = this.props;
+
+ return (
+
+ );
+ }
+
+ render() {
+ const {
+ className,
+ inputClassName,
+ placeholder,
+ hasError,
+ hasWarning
+ } = this.props;
+
+ const {
+ value,
+ suggestions,
+ isFocused
+ } = this.state;
+
+ const inputProps = {
+ className: inputClassName,
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onInputChange,
+ onKeyDown: this.onInputKeyDown,
+ onFocus: this.onInputFocus,
+ onBlur: this.onInputBlur
+ };
+
+ const theme = {
+ container: classNames(
+ className,
+ isFocused && styles.isFocused,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning,
+ ),
+ containerOpen: styles.containerOpen,
+ suggestionsContainer: styles.suggestionsContainer,
+ suggestionsList: styles.suggestionsList,
+ suggestion: styles.suggestion,
+ suggestionHighlighted: styles.suggestionHighlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+export const tagShape = {
+ id: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]).isRequired,
+ name: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired
+};
+
+TagInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string.isRequired,
+ 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,
+ delimiters: PropTypes.arrayOf(PropTypes.number).isRequired,
+ minQueryLength: PropTypes.number.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ tagComponent: PropTypes.func.isRequired,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired
+};
+
+TagInput.defaultProps = {
+ className: styles.inputContainer,
+ inputClassName: styles.input,
+ allowNew: true,
+ kind: kinds.INFO,
+ placeholder: '',
+ // Tab, enter, space and comma
+ delimiters: [9, 13, 32, 188],
+ minQueryLength: 1,
+ tagComponent: TagInputTag
+};
+
+export default TagInput;
diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js
new file mode 100644
index 000000000..5265e9e4f
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputConnector.js
@@ -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 (
+
+ );
+ }
+}
+
+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);
diff --git a/frontend/src/Components/Form/TagInputInput.css b/frontend/src/Components/Form/TagInputInput.css
new file mode 100644
index 000000000..182320b1a
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputInput.css
@@ -0,0 +1,6 @@
+.inputContainer {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 6px 16px;
+ cursor: default;
+}
diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js
new file mode 100644
index 000000000..8bd075774
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputInput.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import { tagShape } from './TagInput';
+import styles from './TagInputInput.css';
+
+class TagInputInput extends Component {
+
+ onMouseDown = (event) => {
+ event.preventDefault();
+
+ const {
+ isFocused,
+ onInputContainerPress
+ } = this.props;
+
+ if (isFocused) {
+ return;
+ }
+
+ onInputContainerPress();
+ }
+
+ render() {
+ const {
+ className,
+ tags,
+ inputProps,
+ kind,
+ tagComponent: TagComponent,
+ onTagDelete
+ } = this.props;
+
+ return (
+
+ {
+ tags.map((tag, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+TagInputInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ inputProps: PropTypes.object.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ isFocused: PropTypes.bool.isRequired,
+ tagComponent: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired,
+ onInputContainerPress: PropTypes.func.isRequired
+};
+
+TagInputInput.defaultProps = {
+ className: styles.inputContainer
+};
+
+export default TagInputInput;
diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js
new file mode 100644
index 000000000..ff1e0e2db
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputTag.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import { tagShape } from './TagInput';
+
+class TagInputTag extends Component {
+
+ //
+ // Listeners
+
+ onDelete = () => {
+ const {
+ index,
+ tag,
+ onDelete
+ } = this.props;
+
+ onDelete({
+ index,
+ id: tag.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ tag,
+ kind
+ } = this.props;
+
+ return (
+
+
+ {tag.name}
+
+
+ );
+ }
+}
+
+TagInputTag.propTypes = {
+ index: PropTypes.number.isRequired,
+ tag: PropTypes.shape(tagShape),
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ onDelete: PropTypes.func.isRequired
+};
+
+export default TagInputTag;
diff --git a/frontend/src/Components/Form/TextInput.css b/frontend/src/Components/Form/TextInput.css
new file mode 100644
index 000000000..7fb9f68cc
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.css
@@ -0,0 +1,19 @@
+.input {
+ 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';
+}
diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js
new file mode 100644
index 000000000..92c0f4baf
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.js
@@ -0,0 +1,185 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './TextInput.css';
+
+class TextInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._input = null;
+ this._selectionStart = null;
+ this._selectionEnd = null;
+ this._selectionTimeout = null;
+ this._isMouseTarget = false;
+ }
+
+ componentDidMount() {
+ window.addEventListener('mouseup', this.onDocumentMouseUp);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('mouseup', this.onDocumentMouseUp);
+
+ if (this._selectionTimeout) {
+ this._selectionTimeout = clearTimeout(this._selectionTimeout);
+ }
+ }
+
+ //
+ // Control
+
+ setInputRef = (ref) => {
+ this._input = ref;
+ }
+
+ selectionChange() {
+ if (this._selectionTimeout) {
+ this._selectionTimeout = clearTimeout(this._selectionTimeout);
+ }
+
+ this._selectionTimeout = setTimeout(() => {
+ const selectionStart = this._input.selectionStart;
+ const selectionEnd = this._input.selectionEnd;
+
+ const selectionChanged = (
+ this._selectionStart !== selectionStart ||
+ this._selectionEnd !== selectionEnd
+ );
+
+ this._selectionStart = selectionStart;
+ this._selectionEnd = selectionEnd;
+
+ if (this.props.onSelectionChange && selectionChanged) {
+ this.props.onSelectionChange(selectionStart, selectionEnd);
+ }
+ }, 10);
+ }
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ const {
+ name,
+ type,
+ onChange
+ } = this.props;
+
+ const payload = {
+ name,
+ value: event.target.value
+ };
+
+ // Also return the files for a file input type.
+
+ if (type === 'file') {
+ payload.files = event.target.files;
+ }
+
+ onChange(payload);
+ }
+
+ onFocus = (event) => {
+ if (this.props.onFocus) {
+ this.props.onFocus(event);
+ }
+
+ this.selectionChange();
+ }
+
+ onKeyUp = () => {
+ this.selectionChange();
+ }
+
+ onMouseDown = () => {
+ this._isMouseTarget = true;
+ }
+
+ onMouseUp = () => {
+ this.selectionChange();
+ }
+
+ onDocumentMouseUp = () => {
+ if (this._isMouseTarget) {
+ this.selectionChange();
+ }
+
+ this._isMouseTarget = false;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ type,
+ readOnly,
+ autoFocus,
+ placeholder,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ hasButton,
+ onBlur
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+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,
+ onBlur: PropTypes.func,
+ onSelectionChange: PropTypes.func
+};
+
+TextInput.defaultProps = {
+ className: styles.input,
+ type: 'text',
+ readOnly: false,
+ autoFocus: false,
+ value: ''
+};
+
+export default TextInput;
diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js
new file mode 100644
index 000000000..587066610
--- /dev/null
+++ b/frontend/src/Components/Form/TextTagInputConnector.js
@@ -0,0 +1,95 @@
+
+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 TagInput from './TagInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ (tags) => {
+ const tagsArray = Array.isArray(tags) ? tags : split(tags);
+
+ return {
+ tags: tagsArray.reduce((result, tag) => {
+ if (tag) {
+ result.push({
+ id: tag,
+ name: tag
+ });
+ }
+
+ return result;
+ }, []),
+ valueArray: tagsArray
+ };
+ }
+ );
+}
+
+class TextTagInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ name,
+ valueArray,
+ onChange
+ } = this.props;
+
+ // Split and trim tags before adding them to the list, this will
+ // cleanse tags pasted in that had commas and spaces which leads
+ // to oddities with restrictions (as an example).
+
+ const newValue = [...valueArray];
+ const newTags = split(tag.name);
+
+ newTags.forEach((newTag) => {
+ newValue.push(newTag.trim());
+ });
+
+ onChange({ name, value: newValue.join(',') });
+ }
+
+ onTagDelete = ({ index }) => {
+ const {
+ name,
+ valueArray,
+ onChange
+ } = this.props;
+
+ const newValue = [...valueArray];
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue.join(',')
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TextTagInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ valueArray: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, null)(TextTagInputConnector);
diff --git a/frontend/src/Components/HeartRating.css b/frontend/src/Components/HeartRating.css
new file mode 100644
index 000000000..705adfcae
--- /dev/null
+++ b/frontend/src/Components/HeartRating.css
@@ -0,0 +1,4 @@
+.heart {
+ margin-right: 5px;
+ color: $themeRed;
+}
diff --git a/frontend/src/Components/HeartRating.js b/frontend/src/Components/HeartRating.js
new file mode 100644
index 000000000..98c3f817e
--- /dev/null
+++ b/frontend/src/Components/HeartRating.js
@@ -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 (
+
+
+
+ {rating * 10}%
+
+ );
+}
+
+HeartRating.propTypes = {
+ rating: PropTypes.number.isRequired,
+ iconSize: PropTypes.number.isRequired
+};
+
+HeartRating.defaultProps = {
+ iconSize: 14
+};
+
+export default HeartRating;
diff --git a/frontend/src/Components/Icon.css b/frontend/src/Components/Icon.css
new file mode 100644
index 000000000..df1ff5327
--- /dev/null
+++ b/frontend/src/Components/Icon.css
@@ -0,0 +1,27 @@
+.danger {
+ color: $dangerColor;
+}
+
+.default {
+ color: inherit;
+}
+
+.disabled {
+ color: $disabledColor;
+}
+
+.info {
+ color: $infoColor;
+}
+
+.pink {
+ color: $pink;
+}
+
+.success {
+ color: $successColor;
+}
+
+.warning {
+ color: $warningColor;
+}
diff --git a/frontend/src/Components/Icon.js b/frontend/src/Components/Icon.js
new file mode 100644
index 000000000..ae3f01c10
--- /dev/null
+++ b/frontend/src/Components/Icon.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { kinds } from 'Helpers/Props';
+import classNames from 'classnames';
+import styles from './Icon.css';
+
+function Icon(props) {
+ const {
+ containerClassName,
+ className,
+ name,
+ kind,
+ size,
+ title,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ const icon = (
+
+ );
+
+ if (title) {
+ return (
+
+ {icon}
+
+ );
+ }
+
+ return icon;
+}
+
+Icon.propTypes = {
+ containerClassName: PropTypes.string,
+ className: PropTypes.string,
+ name: PropTypes.object.isRequired,
+ kind: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ title: PropTypes.string,
+ isSpinning: PropTypes.bool.isRequired,
+ fixedWidth: PropTypes.bool.isRequired
+};
+
+Icon.defaultProps = {
+ kind: kinds.DEFAULT,
+ size: 14,
+ isSpinning: false,
+ fixedWidth: false
+};
+
+export default Icon;
diff --git a/frontend/src/Components/Label.css b/frontend/src/Components/Label.css
new file mode 100644
index 000000000..df17427d9
--- /dev/null
+++ b/frontend/src/Components/Label.css
@@ -0,0 +1,111 @@
+.label {
+ display: inline-block;
+ margin: 2px;
+ border: 1px solid;
+ border-radius: 2px;
+ color: $white;
+ text-align: center;
+ white-space: nowrap;
+ 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;
+ }
+}
+
+.disabled {
+ border-color: $disabledColor;
+ background-color: $disabledColor;
+
+ &.outline {
+ color: $disabledColor;
+ }
+}
+
+.info {
+ border-color: $infoColor;
+ background-color: $infoColor;
+
+ &.outline {
+ color: $infoColor;
+ }
+}
+
+.inverse {
+ border-color: $lightGray;
+ background-color: $lightGray;
+ color: $defaultColor;
+
+ &.outline {
+ background-color: $defaultColor !important;
+ color: $lightGray;
+ }
+}
+
+.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-weight: bold;
+ font-size: $defaultFontSize;
+}
+
+/** Outline **/
+
+.outline {
+ background-color: $white;
+}
diff --git a/frontend/src/Components/Label.js b/frontend/src/Components/Label.js
new file mode 100644
index 000000000..528974204
--- /dev/null
+++ b/frontend/src/Components/Label.js
@@ -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 (
+
+ {children}
+
+ );
+}
+
+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;
diff --git a/frontend/src/Components/Link/Button.css b/frontend/src/Components/Link/Button.css
new file mode 100644
index 000000000..8913c4ab8
--- /dev/null
+++ b/frontend/src/Components/Link/Button.css
@@ -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;
+}
diff --git a/frontend/src/Components/Link/Button.js b/frontend/src/Components/Link/Button.js
new file mode 100644
index 000000000..87d9fff78
--- /dev/null
+++ b/frontend/src/Components/Link/Button.js
@@ -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 (
+
+ {children}
+
+ );
+ }
+
+}
+
+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;
diff --git a/frontend/src/Components/Link/ClipboardButton.css b/frontend/src/Components/Link/ClipboardButton.css
new file mode 100644
index 000000000..09ed883cb
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.css
@@ -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;
+ }
+}
diff --git a/frontend/src/Components/Link/ClipboardButton.js b/frontend/src/Components/Link/ClipboardButton.js
new file mode 100644
index 000000000..54789de52
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.js
@@ -0,0 +1,127 @@
+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 (
+
+
+ {
+ showSuccess &&
+
+
+
+ }
+
+ {
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ClipboardButton.propTypes = {
+ value: PropTypes.string.isRequired
+};
+
+export default ClipboardButton;
diff --git a/frontend/src/Components/Link/IconButton.css b/frontend/src/Components/Link/IconButton.css
new file mode 100644
index 000000000..2c85173a1
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.css
@@ -0,0 +1,21 @@
+.button {
+ composes: link from 'Components/Link/Link.css';
+
+ display: inline-block;
+ 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;
+ }
+
+ &.isDisabled {
+ color: $iconButtonDisabledColor;
+ }
+}
diff --git a/frontend/src/Components/Link/IconButton.js b/frontend/src/Components/Link/IconButton.js
new file mode 100644
index 000000000..26aacb0bf
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import Icon from 'Components/Icon';
+import Link from './Link';
+import styles from './IconButton.css';
+
+function IconButton(props) {
+ const {
+ className,
+ iconClassName,
+ name,
+ kind,
+ size,
+ isSpinning,
+ isDisabled,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+IconButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ iconClassName: PropTypes.string,
+ kind: PropTypes.string,
+ name: PropTypes.object.isRequired,
+ size: PropTypes.number,
+ isSpinning: PropTypes.bool,
+ isDisabled: PropTypes.bool
+};
+
+IconButton.defaultProps = {
+ className: styles.button,
+ size: 12
+};
+
+export default IconButton;
diff --git a/frontend/src/Components/Link/Link.css b/frontend/src/Components/Link/Link.css
new file mode 100644
index 000000000..ff0ed8d0c
--- /dev/null
+++ b/frontend/src/Components/Link/Link.css
@@ -0,0 +1,24 @@
+.link {
+ margin: 0;
+ padding: 0;
+ outline: none;
+ border: 0;
+ background: none;
+ color: inherit;
+ text-align: inherit;
+ text-decoration: none;
+ cursor: pointer;
+
+ &:global(.isDisabled) {
+ cursor: default;
+ }
+}
+
+.to {
+ color: $linkColor;
+
+ &:hover {
+ color: $linkHoverColor;
+ text-decoration: underline;
+ }
+}
diff --git a/frontend/src/Components/Link/Link.js b/frontend/src/Components/Link/Link.js
new file mode 100644
index 000000000..3a609c99c
--- /dev/null
+++ b/frontend/src/Components/Link/Link.js
@@ -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;
diff --git a/frontend/src/Components/Link/SpinnerButton.css b/frontend/src/Components/Link/SpinnerButton.css
new file mode 100644
index 000000000..cfccd0f06
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerButton.css
@@ -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%;
+ visibility: hidden;
+ }
+}
diff --git a/frontend/src/Components/Link/SpinnerButton.js b/frontend/src/Components/Link/SpinnerButton.js
new file mode 100644
index 000000000..1507220d6
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerButton.js
@@ -0,0 +1,57 @@
+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 (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+SpinnerButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ isSpinning: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool,
+ spinnerIcon: PropTypes.object.isRequired,
+ children: PropTypes.node
+};
+
+SpinnerButton.defaultProps = {
+ className: styles.button,
+ spinnerIcon: icons.SPINNER
+};
+
+export default SpinnerButton;
diff --git a/frontend/src/Components/Link/SpinnerErrorButton.css b/frontend/src/Components/Link/SpinnerErrorButton.css
new file mode 100644
index 000000000..5f4e68545
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerErrorButton.css
@@ -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;
+ }
+}
diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js
new file mode 100644
index 000000000..0575db094
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerErrorButton.js
@@ -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 (
+
+
+ {
+ showIcon &&
+
+
+
+ }
+
+ {
+
+ {
+ children
+ }
+
+ }
+
+
+ );
+ }
+}
+
+SpinnerErrorButton.propTypes = {
+ isSpinning: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ children: PropTypes.node.isRequired
+};
+
+export default SpinnerErrorButton;
diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js
new file mode 100644
index 000000000..a804fafc5
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerIconButton.js
@@ -0,0 +1,38 @@
+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 (
+
+ );
+}
+
+SpinnerIconButton.propTypes = {
+ name: PropTypes.object.isRequired,
+ spinningName: PropTypes.object.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isSpinning: PropTypes.bool.isRequired
+};
+
+SpinnerIconButton.defaultProps = {
+ spinningName: icons.SPINNER,
+ isDisabled: false,
+ isSpinning: false
+};
+
+export default SpinnerIconButton;
diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css
new file mode 100644
index 000000000..fd224b1d6
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingIndicator.css
@@ -0,0 +1,49 @@
+.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: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8);
+ animation-fill-mode: both;
+}
+
+@keyframes rippleContainer {
+ 0% {
+ opacity: 1;
+ transform: scale(0.1);
+ }
+
+ 70% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/Components/Loading/LoadingIndicator.js b/frontend/src/Components/Loading/LoadingIndicator.js
new file mode 100644
index 000000000..5f9a15b1a
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingIndicator.js
@@ -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 (
+
+ );
+}
+
+LoadingIndicator.propTypes = {
+ className: PropTypes.string,
+ size: PropTypes.number
+};
+
+LoadingIndicator.defaultProps = {
+ className: styles.loading,
+ size: 50
+};
+
+export default LoadingIndicator;
diff --git a/frontend/src/Components/Loading/LoadingMessage.css b/frontend/src/Components/Loading/LoadingMessage.css
new file mode 100644
index 000000000..a7b39e76f
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingMessage.css
@@ -0,0 +1,6 @@
+.loadingMessage {
+ margin: 50px 10px 0;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
diff --git a/frontend/src/Components/Loading/LoadingMessage.js b/frontend/src/Components/Loading/LoadingMessage.js
new file mode 100644
index 000000000..f080731c3
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingMessage.js
@@ -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 (
+
+ {message}
+
+ );
+}
+
+export default LoadingMessage;
diff --git a/frontend/src/Components/Measure.js b/frontend/src/Components/Measure.js
new file mode 100644
index 000000000..a2f113de7
--- /dev/null
+++ b/frontend/src/Components/Measure.js
@@ -0,0 +1,38 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactMeasure from 'react-measure';
+
+class Measure extends Component {
+
+ //
+ // Lifecycle
+
+ componentWillUnmount() {
+ this.onMeasure.cancel();
+ }
+
+ //
+ // Listeners
+
+ onMeasure = _.debounce((payload) => {
+ this.props.onMeasure(payload);
+ }, 250, { leading: true, trailing: false })
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+Measure.propTypes = {
+ onMeasure: PropTypes.func.isRequired
+};
+
+export default Measure;
diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css
new file mode 100644
index 000000000..34991aed9
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenu.css
@@ -0,0 +1,9 @@
+.filterMenu {
+ composes: menu from './Menu.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .filterMenu {
+ margin-right: 10px;
+ }
+}
diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js
new file mode 100644
index 000000000..d37876c22
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenu.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FilterMenuContent from './FilterMenuContent';
+import Menu from './Menu';
+import ToolbarMenuButton from './ToolbarMenuButton';
+import styles from './FilterMenu.css';
+
+class FilterMenu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isFilterModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCustomFiltersPress = () => {
+ this.setState({ isFilterModalOpen: true });
+ }
+
+ onFiltersModalClose = () => {
+ this.setState({ isFilterModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render(props) {
+ const {
+ className,
+ isDisabled,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ buttonComponent: ButtonComponent,
+ filterModalConnectorComponent: FilterModalConnectorComponent,
+ filterModalConnectorComponentProps,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const showCustomFilters = !!FilterModalConnectorComponent;
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ showCustomFilters &&
+
+ }
+
+ );
+ }
+}
+
+FilterMenu.propTypes = {
+ className: PropTypes.string,
+ isDisabled: PropTypes.bool.isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ buttonComponent: PropTypes.func.isRequired,
+ filterModalConnectorComponent: PropTypes.func,
+ filterModalConnectorComponentProps: PropTypes.object,
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+FilterMenu.defaultProps = {
+ className: styles.filterMenu,
+ isDisabled: false,
+ buttonComponent: ToolbarMenuButton
+};
+
+export default FilterMenu;
diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js
new file mode 100644
index 000000000..7463e2c9e
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenuContent.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuContent from './MenuContent';
+import FilterMenuItem from './FilterMenuItem';
+import MenuItem from './MenuItem';
+import MenuItemSeparator from './MenuItemSeparator';
+
+class FilterMenuContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ showCustomFilters,
+ onFilterSelect,
+ onCustomFiltersPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {
+ filters.map((filter) => {
+ return (
+
+ {filter.label}
+
+ );
+ })
+ }
+
+ {
+ customFilters.map((filter) => {
+ return (
+
+ {filter.label}
+
+ );
+ })
+ }
+
+ {
+ showCustomFilters &&
+
+ }
+
+ {
+ showCustomFilters &&
+
+ Custom Filters
+
+ }
+
+ );
+ }
+}
+
+FilterMenuContent.propTypes = {
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ showCustomFilters: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onCustomFiltersPress: PropTypes.func.isRequired
+};
+
+FilterMenuContent.defaultProps = {
+ showCustomFilters: false
+};
+
+export default FilterMenuContent;
diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js
new file mode 100644
index 000000000..d2c495187
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenuItem.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import SelectedMenuItem from './SelectedMenuItem';
+
+class FilterMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ filterKey,
+ onPress
+ } = this.props;
+
+ onPress(filterKey);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterKey,
+ selectedFilterKey,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+FilterMenuItem.propTypes = {
+ filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default FilterMenuItem;
diff --git a/frontend/src/Components/Menu/Menu.css b/frontend/src/Components/Menu/Menu.css
new file mode 100644
index 000000000..9cce48fee
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.css
@@ -0,0 +1,7 @@
+.tether {
+ z-index: 2000;
+}
+
+.menu {
+ position: relative;
+}
diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js
new file mode 100644
index 000000000..da778bb7a
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.js
@@ -0,0 +1,207 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import { align } from 'Helpers/Props';
+import styles from './Menu.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [align.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'top right',
+ targetAttachment: 'bottom right'
+ },
+
+ [align.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'top left',
+ targetAttachment: 'bottom left'
+ }
+};
+
+class Menu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMenuOpen: false,
+ maxHeight: 0
+ };
+ }
+
+ componentDidMount() {
+ this.setMaxHeight();
+ }
+
+ componentWillUnmount() {
+ this._removeListener();
+ }
+
+ //
+ // Control
+
+ getMaxHeight() {
+ if (!this.props.enforceMaxHeight) {
+ return;
+ }
+
+ const menu = ReactDOM.findDOMNode(this.refs.menu);
+
+ if (!menu) {
+ return;
+ }
+
+ const { bottom } = menu.getBoundingClientRect();
+ const maxHeight = window.innerHeight - bottom;
+
+ return maxHeight;
+ }
+
+ setMaxHeight() {
+ this.setState({
+ maxHeight: this.getMaxHeight()
+ });
+ }
+
+ _addListener() {
+ // Listen to resize events on the window and scroll events
+ // on all elements to ensure the menu is the best size possible.
+ // Listen for click events on the window to support closing the
+ // menu on clicks outside.
+
+ window.addEventListener('resize', this.onWindowResize);
+ window.addEventListener('scroll', this.onWindowScroll, { capture: true });
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('resize', this.onWindowResize);
+ window.removeEventListener('scroll', this.onWindowScroll, { capture: true });
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const menu = ReactDOM.findDOMNode(this.refs.menu);
+ const menuContent = ReactDOM.findDOMNode(this.refs.menuContent);
+
+ if (!menu) {
+ return;
+ }
+
+ if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) {
+ this.setState({ isMenuOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onWindowResize = () => {
+ this.setMaxHeight();
+ }
+
+ onWindowScroll = () => {
+ this.setMaxHeight();
+ }
+
+ onMenuButtonPress = () => {
+ const state = {
+ isMenuOpen: !this.state.isMenuOpen
+ };
+
+ if (this.state.isMenuOpen) {
+ this._removeListener();
+ } else {
+ state.maxHeight = this.getMaxHeight();
+ this._addListener();
+ }
+
+ this.setState(state);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ alignMenu
+ } = this.props;
+
+ const {
+ maxHeight,
+ isMenuOpen
+ } = this.state;
+
+ const childrenArray = React.Children.toArray(children);
+ const button = React.cloneElement(
+ childrenArray[0],
+ {
+ onPress: this.onMenuButtonPress
+ }
+ );
+
+ const content = React.cloneElement(
+ childrenArray[1],
+ {
+ ref: 'menuContent',
+ alignMenu,
+ maxHeight,
+ isOpen: isMenuOpen
+ }
+ );
+
+ return (
+
+
+ {button}
+
+
+ {
+ isMenuOpen &&
+ content
+ }
+
+ );
+ }
+}
+
+Menu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]),
+ enforceMaxHeight: PropTypes.bool.isRequired
+};
+
+Menu.defaultProps = {
+ className: styles.menu,
+ alignMenu: align.LEFT,
+ enforceMaxHeight: true
+};
+
+export default Menu;
diff --git a/frontend/src/Components/Menu/MenuButton.css b/frontend/src/Components/Menu/MenuButton.css
new file mode 100644
index 000000000..ff4f0dadb
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.css
@@ -0,0 +1,30 @@
+.menuButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+}
+
+.isDisabled {
+ color: $disabledColor;
+
+ pointer-events: none;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .menuButton {
+ &::after {
+ margin-left: 0;
+ content: '\25BE';
+ }
+ }
+}
diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js
new file mode 100644
index 000000000..477334a1d
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import Link from 'Components/Link/Link';
+import styles from './MenuButton.css';
+
+class MenuButton extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ isDisabled,
+ onPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+MenuButton.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onPress: PropTypes.func
+};
+
+MenuButton.defaultProps = {
+ className: styles.menuButton,
+ isDisabled: false
+};
+
+export default MenuButton;
diff --git a/frontend/src/Components/Menu/MenuContent.css b/frontend/src/Components/Menu/MenuContent.css
new file mode 100644
index 000000000..0acc07390
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.css
@@ -0,0 +1,11 @@
+.menuContent {
+ display: flex;
+ flex-direction: column;
+ background-color: $toolbarMenuItemBackgroundColor;
+ line-height: 20px;
+}
+
+.scroller {
+ display: flex;
+ flex-direction: column;
+}
diff --git a/frontend/src/Components/Menu/MenuContent.js b/frontend/src/Components/Menu/MenuContent.js
new file mode 100644
index 000000000..1acacf80f
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './MenuContent.css';
+
+class MenuContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ maxHeight
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+MenuContent.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ maxHeight: PropTypes.number
+};
+
+MenuContent.defaultProps = {
+ className: styles.menuContent
+};
+
+export default MenuContent;
diff --git a/frontend/src/Components/Menu/MenuItem.css b/frontend/src/Components/Menu/MenuItem.css
new file mode 100644
index 000000000..bae1a649c
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItem.css
@@ -0,0 +1,19 @@
+.menuItem {
+ @add-mixin truncate;
+
+ display: block;
+ flex-shrink: 0;
+ padding: 10px 20px;
+ min-width: 150px;
+ max-width: 250px;
+ background-color: $toolbarMenuItemBackgroundColor;
+ color: $menuItemColor;
+ line-height: 20px;
+
+ &:hover,
+ &:focus {
+ background-color: $toolbarMenuItemHoverBackgroundColor;
+ color: $menuItemHoverColor;
+ text-decoration: none;
+ }
+}
diff --git a/frontend/src/Components/Menu/MenuItem.js b/frontend/src/Components/Menu/MenuItem.js
new file mode 100644
index 000000000..ff083450b
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItem.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './MenuItem.css';
+
+class MenuItem extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+MenuItem.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+MenuItem.defaultProps = {
+ className: styles.menuItem
+};
+
+export default MenuItem;
diff --git a/frontend/src/Components/Menu/MenuItemSeparator.css b/frontend/src/Components/Menu/MenuItemSeparator.css
new file mode 100644
index 000000000..a867e3153
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItemSeparator.css
@@ -0,0 +1,5 @@
+.separator {
+ overflow: hidden;
+ height: 1px;
+ background-color: $themeDarkColor;
+}
diff --git a/frontend/src/Components/Menu/MenuItemSeparator.js b/frontend/src/Components/Menu/MenuItemSeparator.js
new file mode 100644
index 000000000..e586670c9
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItemSeparator.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import styles from './MenuItemSeparator.css';
+
+function MenuItemSeparator() {
+ return (
+
+ );
+}
+
+export default MenuItemSeparator;
diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css
new file mode 100644
index 000000000..e6954f600
--- /dev/null
+++ b/frontend/src/Components/Menu/PageMenuButton.css
@@ -0,0 +1,11 @@
+.menuButton {
+ composes: menuButton from './MenuButton.css';
+
+ &:hover {
+ color: #666;
+ }
+}
+
+.label {
+ margin-left: 5px;
+}
diff --git a/frontend/src/Components/Menu/PageMenuButton.js b/frontend/src/Components/Menu/PageMenuButton.js
new file mode 100644
index 000000000..abbfc98f8
--- /dev/null
+++ b/frontend/src/Components/Menu/PageMenuButton.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import MenuButton from 'Components/Menu/MenuButton';
+import styles from './PageMenuButton.css';
+
+function PageMenuButton(props) {
+ const {
+ iconName,
+ text,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+ {text}
+
+
+ );
+}
+
+PageMenuButton.propTypes = {
+ iconName: PropTypes.object.isRequired,
+ text: PropTypes.string
+};
+
+export default PageMenuButton;
diff --git a/frontend/src/Components/Menu/SelectedMenuItem.css b/frontend/src/Components/Menu/SelectedMenuItem.css
new file mode 100644
index 000000000..739419d69
--- /dev/null
+++ b/frontend/src/Components/Menu/SelectedMenuItem.css
@@ -0,0 +1,15 @@
+.item {
+ display: flex;
+ justify-content: space-between;
+ white-space: nowrap;
+}
+
+.isSelected {
+ visibility: visible;
+ margin-left: 20px;
+}
+
+.isNotSelected {
+ visibility: hidden;
+ margin-left: 20px;
+}
diff --git a/frontend/src/Components/Menu/SelectedMenuItem.js b/frontend/src/Components/Menu/SelectedMenuItem.js
new file mode 100644
index 000000000..8b0805c57
--- /dev/null
+++ b/frontend/src/Components/Menu/SelectedMenuItem.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import MenuItem from './MenuItem';
+import styles from './SelectedMenuItem.css';
+
+class SelectedMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ onPress
+ } = this.props;
+
+ onPress(name);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ selectedIconName,
+ isSelected,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+
+
+ );
+ }
+}
+
+SelectedMenuItem.propTypes = {
+ name: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ selectedIconName: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+SelectedMenuItem.defaultProps = {
+ selectedIconName: icons.CHECK
+};
+
+export default SelectedMenuItem;
diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js
new file mode 100644
index 000000000..a9a6a184e
--- /dev/null
+++ b/frontend/src/Components/Menu/SortMenu.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Menu from 'Components/Menu/Menu';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+
+function SortMenu(props) {
+ const {
+ className,
+ children,
+ isDisabled,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+SortMenu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool.isRequired
+};
+
+SortMenu.defaultProps = {
+ isDisabled: false
+};
+
+export default SortMenu;
diff --git a/frontend/src/Components/Menu/SortMenuItem.js b/frontend/src/Components/Menu/SortMenuItem.js
new file mode 100644
index 000000000..e35864ae6
--- /dev/null
+++ b/frontend/src/Components/Menu/SortMenuItem.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import SelectedMenuItem from './SelectedMenuItem';
+
+function SortMenuItem(props) {
+ const {
+ name,
+ sortKey,
+ sortDirection,
+ ...otherProps
+ } = props;
+
+ const isSelected = name === sortKey;
+
+ return (
+
+ );
+}
+
+SortMenuItem.propTypes = {
+ name: PropTypes.string,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ onPress: PropTypes.func.isRequired
+};
+
+SortMenuItem.defaultProps = {
+ name: null
+};
+
+export default SortMenuItem;
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css
new file mode 100644
index 000000000..c8a905e17
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.css
@@ -0,0 +1,11 @@
+.menuButton {
+ composes: menuButton from './MenuButton.css';
+
+ width: $toolbarButtonWidth;
+ height: $toolbarHeight;
+ text-align: center;
+}
+
+.label {
+ composes: label from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js
new file mode 100644
index 000000000..b80d6eaa3
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import MenuButton from 'Components/Menu/MenuButton';
+import styles from './ToolbarMenuButton.css';
+
+function ToolbarMenuButton(props) {
+ const {
+ iconName,
+ text,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+ToolbarMenuButton.propTypes = {
+ iconName: PropTypes.object.isRequired,
+ text: PropTypes.string
+};
+
+export default ToolbarMenuButton;
diff --git a/frontend/src/Components/Menu/ViewMenu.js b/frontend/src/Components/Menu/ViewMenu.js
new file mode 100644
index 000000000..60c77e003
--- /dev/null
+++ b/frontend/src/Components/Menu/ViewMenu.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Menu from 'Components/Menu/Menu';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+
+function ViewMenu(props) {
+ const {
+ children,
+ isDisabled,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+ViewMenu.propTypes = {
+ children: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool.isRequired
+};
+
+ViewMenu.defaultProps = {
+ isDisabled: false
+};
+
+export default ViewMenu;
diff --git a/frontend/src/Components/Menu/ViewMenuItem.js b/frontend/src/Components/Menu/ViewMenuItem.js
new file mode 100644
index 000000000..d355d6e94
--- /dev/null
+++ b/frontend/src/Components/Menu/ViewMenuItem.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectedMenuItem from './SelectedMenuItem';
+
+function ViewMenuItem(props) {
+ const {
+ name,
+ selectedView,
+ ...otherProps
+ } = props;
+
+ const isSelected = name === selectedView;
+
+ return (
+
+ );
+}
+
+ViewMenuItem.propTypes = {
+ name: PropTypes.string,
+ selectedView: PropTypes.string.isRequired
+};
+
+export default ViewMenuItem;
diff --git a/frontend/src/Components/Modal/ConfirmModal.js b/frontend/src/Components/Modal/ConfirmModal.js
new file mode 100644
index 000000000..5bb783d43
--- /dev/null
+++ b/frontend/src/Components/Modal/ConfirmModal.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function ConfirmModal(props) {
+ const {
+ isOpen,
+ kind,
+ size,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ hideCancelButton,
+ isSpinning,
+ onConfirm,
+ onCancel
+ } = props;
+
+ return (
+
+
+ {title}
+
+
+ {message}
+
+
+
+ {
+ !hideCancelButton &&
+
+ {cancelLabel}
+
+ }
+
+
+ {confirmLabel}
+
+
+
+
+ );
+}
+
+ConfirmModal.propTypes = {
+ className: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all),
+ size: PropTypes.oneOf(sizes.all),
+ title: PropTypes.string.isRequired,
+ message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ confirmLabel: PropTypes.string,
+ cancelLabel: PropTypes.string,
+ hideCancelButton: PropTypes.bool,
+ isSpinning: PropTypes.bool.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired
+};
+
+ConfirmModal.defaultProps = {
+ kind: kinds.PRIMARY,
+ size: sizes.MEDIUM,
+ confirmLabel: 'OK',
+ cancelLabel: 'Cancel',
+ isSpinning: false
+};
+
+export default ConfirmModal;
diff --git a/frontend/src/Components/Modal/Modal.css b/frontend/src/Components/Modal/Modal.css
new file mode 100644
index 000000000..a9b2a27ae
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.css
@@ -0,0 +1,92 @@
+.modalContainer {
+ position: absolute;
+ top: 0;
+ z-index: 1000;
+ width: 100%;
+ height: 100%;
+}
+
+.modalBackdrop {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: $modalBackdropBackgroundColor;
+ opacity: 1;
+}
+
+.modal {
+ position: relative;
+ display: flex;
+ max-height: 90%;
+ border-radius: 6px;
+ opacity: 1;
+}
+
+.modalOpen {
+ /* Prevent the body from scrolling when the modal is open */
+ overflow: hidden !important;
+}
+
+/*
+ * Sizes
+ */
+
+.small {
+ composes: modal;
+
+ width: 480px;
+}
+
+.medium {
+ composes: modal;
+
+ width: 720px;
+}
+
+.large {
+ composes: modal;
+
+ width: 1080px;
+}
+
+.extraLarge {
+ composes: modal;
+
+ width: 1280px;
+}
+
+@media only screen and (max-width: $breakpointExtraLarge) {
+ .modal.extraLarge {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .modal.large {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .modal.small,
+ .modal.medium {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalContainer {
+ position: fixed;
+ }
+
+ .modal.small,
+ .modal.medium,
+ .modal.large,
+ .modal.extraLarge {
+ max-height: 100%;
+ width: 100%;
+ height: 100% !important;
+ }
+}
diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js
new file mode 100644
index 000000000..a9de82c6d
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.js
@@ -0,0 +1,215 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import classNames from 'classnames';
+import elementClass from 'element-class';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { sizes } from 'Helpers/Props';
+import ErrorBoundary from 'Components/Error/ErrorBoundary';
+import ModalError from './ModalError';
+import styles from './Modal.css';
+
+const openModals = [];
+
+function removeFromOpenModals(id) {
+ const index = openModals.indexOf(id);
+
+ if (index >= 0) {
+ openModals.splice(index, 1);
+ }
+}
+
+class Modal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._node = document.getElementById('modal-root');
+ this._backgroundRef = null;
+ this._modalId = getUniqueElememtId();
+ }
+
+ componentDidMount() {
+ if (this.props.isOpen) {
+ this._openModal();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isOpen
+ } = this.props;
+
+ if (!prevProps.isOpen && isOpen) {
+ this._openModal();
+ } else if (prevProps.isOpen && !isOpen) {
+ this._closeModal();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.props.isOpen) {
+ this._closeModal();
+ }
+ }
+
+ //
+ // Control
+
+ _setBackgroundRef = (ref) => {
+ this._backgroundRef = ref;
+ }
+
+ _openModal() {
+ openModals.push(this._modalId);
+ window.addEventListener('keydown', this.onKeyDown);
+
+ if (openModals.length === 1) {
+ elementClass(document.body).add(styles.modalOpen);
+ }
+ }
+
+ _closeModal() {
+ removeFromOpenModals(this._modalId);
+ window.removeEventListener('keydown', this.onKeyDown);
+
+ if (openModals.length === 0) {
+ elementClass(document.body).remove(styles.modalOpen);
+ }
+ }
+
+ _isBackdropTarget(event) {
+ const targetElement = this._findEventTarget(event);
+
+ if (targetElement) {
+ const backgroundElement = ReactDOM.findDOMNode(this._backgroundRef);
+
+ return backgroundElement.isEqualNode(targetElement);
+ }
+
+ return false;
+ }
+
+ _findEventTarget(event) {
+ const changedTouches = event.changedTouches;
+
+ if (!changedTouches) {
+ return event.target;
+ }
+
+ if (changedTouches.length === 1) {
+ const touch = changedTouches[0];
+
+ return document.elementFromPoint(touch.clientX, touch.clientY);
+ }
+ }
+
+ //
+ // Listeners
+
+ onBackdropBeginPress = (event) => {
+ this._isBackdropPressed = this._isBackdropTarget(event);
+ }
+
+ onBackdropEndPress = (event) => {
+ const {
+ closeOnBackgroundClick,
+ onModalClose
+ } = this.props;
+
+ if (
+ this._isBackdropPressed &&
+ this._isBackdropTarget(event) &&
+ closeOnBackgroundClick
+ ) {
+ onModalClose();
+ }
+
+ this._isBackdropPressed = false;
+ }
+
+ onKeyDown = (event) => {
+ const keyCode = event.keyCode;
+
+ if (keyCode === keyCodes.ESCAPE) {
+ if (openModals.indexOf(this._modalId) === openModals.length - 1) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.props.onModalClose();
+ }
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ backdropClassName,
+ size,
+ children,
+ isOpen,
+ onModalClose
+ } = this.props;
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return ReactDOM.createPortal(
+ ,
+ this._node
+ );
+ }
+}
+
+Modal.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ backdropClassName: PropTypes.string,
+ size: PropTypes.oneOf(sizes.all),
+ children: PropTypes.node,
+ isOpen: PropTypes.bool.isRequired,
+ closeOnBackgroundClick: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+Modal.defaultProps = {
+ className: styles.modal,
+ backdropClassName: styles.modalBackdrop,
+ size: sizes.LARGE,
+ closeOnBackgroundClick: true
+};
+
+export default Modal;
diff --git a/frontend/src/Components/Modal/ModalBody.css b/frontend/src/Components/Modal/ModalBody.css
new file mode 100644
index 000000000..ebeef29de
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalBody.css
@@ -0,0 +1,12 @@
+.modalBody {
+ flex: 1 0 1px;
+ padding: $modalBodyPadding;
+}
+
+.modalScroller {
+ flex-grow: 1;
+}
+
+.innerModalBody {
+ padding: $modalBodyPadding;
+}
diff --git a/frontend/src/Components/Modal/ModalBody.js b/frontend/src/Components/Modal/ModalBody.js
new file mode 100644
index 000000000..a35f2ecf5
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalBody.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './ModalBody.css';
+
+class ModalBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ innerClassName,
+ scrollDirection,
+ children,
+ ...otherProps
+ } = this.props;
+
+ let className = this.props.className;
+ const hasScroller = scrollDirection !== scrollDirections.NONE;
+
+ if (!className) {
+ className = hasScroller ? styles.modalScroller : styles.modalBody;
+ }
+
+ return (
+
+ {
+ hasScroller ?
+
+ {children}
+
:
+ children
+ }
+
+ );
+ }
+
+}
+
+ModalBody.propTypes = {
+ className: PropTypes.string,
+ innerClassName: PropTypes.string,
+ children: PropTypes.node,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL])
+};
+
+ModalBody.defaultProps = {
+ innerClassName: styles.innerModalBody,
+ scrollDirection: scrollDirections.VERTICAL
+};
+
+export default ModalBody;
diff --git a/frontend/src/Components/Modal/ModalContent.css b/frontend/src/Components/Modal/ModalContent.css
new file mode 100644
index 000000000..afd798dfa
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalContent.css
@@ -0,0 +1,23 @@
+.modalContent {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ width: 100%;
+ background-color: $modalBackgroundColor;
+}
+
+.closeButton {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ width: 60px;
+ height: 60px;
+ text-align: center;
+ line-height: 60px;
+
+ &:hover {
+ color: $modalCloseButtonHoverColor;
+ }
+}
diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js
new file mode 100644
index 000000000..655046fe4
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalContent.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './ModalContent.css';
+
+function ModalContent(props) {
+ const {
+ className,
+ children,
+ showCloseButton,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ showCloseButton &&
+
+
+
+ }
+
+ {children}
+
+ );
+}
+
+ModalContent.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node,
+ showCloseButton: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+ModalContent.defaultProps = {
+ className: styles.modalContent,
+ showCloseButton: true
+};
+
+export default ModalContent;
diff --git a/frontend/src/Components/Modal/ModalError.css b/frontend/src/Components/Modal/ModalError.css
new file mode 100644
index 000000000..54dbdbc63
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalError.css
@@ -0,0 +1,15 @@
+.message {
+ composes: message from 'Components/Error/ErrorBoundaryError.css';
+
+ margin: 0;
+ margin-bottom: 30px;
+ font-weight: normal;
+ font-size: 26px;
+}
+
+.details {
+ composes: details from 'Components/Error/ErrorBoundaryError.css';
+
+ margin: 0;
+ margin-top: 20px;
+}
diff --git a/frontend/src/Components/Modal/ModalError.js b/frontend/src/Components/Modal/ModalError.js
new file mode 100644
index 000000000..df99a5b32
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalError.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
+import Button from 'Components/Link/Button';
+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 styles from './ModalError.css';
+
+function ModalError(props) {
+ const {
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ Error
+
+
+
+
+
+
+
+
+ Close
+
+
+ );
+}
+
+ModalError.propTypes = {
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ModalError;
diff --git a/frontend/src/Components/Modal/ModalFooter.css b/frontend/src/Components/Modal/ModalFooter.css
new file mode 100644
index 000000000..3b817d2bf
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalFooter.css
@@ -0,0 +1,23 @@
+.modalFooter {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-shrink: 0;
+ padding: 15px 30px;
+ border-top: 1px solid $borderColor;
+
+ a,
+ button {
+ margin-left: 10px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalFooter {
+ padding: 15px;
+ }
+}
diff --git a/frontend/src/Components/Modal/ModalFooter.js b/frontend/src/Components/Modal/ModalFooter.js
new file mode 100644
index 000000000..0cf8811d3
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalFooter.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './ModalFooter.css';
+
+class ModalFooter extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+ModalFooter.propTypes = {
+ children: PropTypes.node
+};
+
+export default ModalFooter;
diff --git a/frontend/src/Components/Modal/ModalHeader.css b/frontend/src/Components/Modal/ModalHeader.css
new file mode 100644
index 000000000..eab77a9f8
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalHeader.css
@@ -0,0 +1,8 @@
+.modalHeader {
+ @add-mixin truncate;
+
+ flex-shrink: 0;
+ padding: 15px 50px 15px 30px;
+ border-bottom: 1px solid $borderColor;
+ font-size: 18px;
+}
diff --git a/frontend/src/Components/Modal/ModalHeader.js b/frontend/src/Components/Modal/ModalHeader.js
new file mode 100644
index 000000000..52879b57d
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalHeader.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './ModalHeader.css';
+
+class ModalHeader extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+ModalHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default ModalHeader;
diff --git a/frontend/src/Components/MonitorToggleButton.css b/frontend/src/Components/MonitorToggleButton.css
new file mode 100644
index 000000000..794af1e98
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.css
@@ -0,0 +1,11 @@
+.toggleButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ padding: 0;
+ font-size: inherit;
+}
+
+.isDisabled {
+ color: $disabledColor;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js
new file mode 100644
index 000000000..15ac3d7fe
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import styles from './MonitorToggleButton.css';
+
+function getTooltip(monitored, isDisabled) {
+ if (isDisabled) {
+ return 'Cannot toogle monitored state when series is unmonitored';
+ }
+
+ if (monitored) {
+ return 'Monitored, click to unmonitor';
+ }
+
+ return 'Unmonitored, click to monitor';
+}
+
+class MonitorToggleButton extends Component {
+
+ //
+ // Listeners
+
+ onPress = (event) => {
+ const shiftKey = event.nativeEvent.shiftKey;
+
+ this.props.onPress(!this.props.monitored, { shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ monitored,
+ isDisabled,
+ isSaving,
+ size,
+ ...otherProps
+ } = this.props;
+
+ const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
+
+ return (
+
+ );
+ }
+}
+
+MonitorToggleButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ size: PropTypes.number,
+ isDisabled: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+MonitorToggleButton.defaultProps = {
+ className: styles.toggleButton,
+ isDisabled: false,
+ isSaving: false
+};
+
+export default MonitorToggleButton;
diff --git a/frontend/src/Components/NotFound.css b/frontend/src/Components/NotFound.css
new file mode 100644
index 000000000..9aaf1114f
--- /dev/null
+++ b/frontend/src/Components/NotFound.css
@@ -0,0 +1,14 @@
+.container {
+ text-align: center;
+}
+
+.message {
+ margin: 50px 0;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.image {
+ height: 350px;
+}
diff --git a/frontend/src/Components/NotFound.js b/frontend/src/Components/NotFound.js
new file mode 100644
index 000000000..7043da46f
--- /dev/null
+++ b/frontend/src/Components/NotFound.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import styles from './NotFound.css';
+
+function NotFound({ message }) {
+ return (
+
+
+
+ {message}
+
+
+
+
+
+ );
+}
+
+NotFound.propTypes = {
+ message: PropTypes.string.isRequired
+};
+
+NotFound.defaultProps = {
+ message: 'You must be lost, nothing to see here.'
+};
+
+export default NotFound;
diff --git a/frontend/src/Components/Page/ErrorPage.css b/frontend/src/Components/Page/ErrorPage.css
new file mode 100644
index 000000000..e62a82a6b
--- /dev/null
+++ b/frontend/src/Components/Page/ErrorPage.css
@@ -0,0 +1,12 @@
+.page {
+ composes: page from './Page.css';
+
+ margin-top: 20px;
+ text-align: center;
+ font-size: 20px;
+}
+
+.version {
+ margin-top: 20px;
+ font-size: 16px;
+}
diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js
new file mode 100644
index 000000000..891056c2c
--- /dev/null
+++ b/frontend/src/Components/Page/ErrorPage.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import styles from './ErrorPage.css';
+
+function ErrorPage(props) {
+ const {
+ version,
+ isLocalStorageSupported,
+ seriesError,
+ customFiltersError,
+ tagsError,
+ qualityProfilesError,
+ languageProfilesError,
+ uiSettingsError
+ } = props;
+
+ let errorMessage = 'Failed to load Sonarr';
+
+ if (!isLocalStorageSupported) {
+ errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
+ } else if (seriesError) {
+ errorMessage = getErrorMessage(seriesError, 'Failed to load series from API');
+ } else if (customFiltersError) {
+ errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API');
+ } else if (tagsError) {
+ errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API');
+ } else if (qualityProfilesError) {
+ errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API');
+ } else if (languageProfilesError) {
+ errorMessage = getErrorMessage(languageProfilesError, 'Failed to load language profiles from API');
+ } else if (uiSettingsError) {
+ errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API');
+ }
+
+ return (
+
+
+ {errorMessage}
+
+
+
+ Version {version}
+
+
+ );
+}
+
+ErrorPage.propTypes = {
+ version: PropTypes.string.isRequired,
+ isLocalStorageSupported: PropTypes.bool.isRequired,
+ seriesError: PropTypes.object,
+ customFiltersError: PropTypes.object,
+ tagsError: PropTypes.object,
+ qualityProfilesError: PropTypes.object,
+ languageProfilesError: PropTypes.object,
+ uiSettingsError: PropTypes.object
+};
+
+export default ErrorPage;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js
new file mode 100644
index 000000000..a1d106b58
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector';
+
+function KeyboardShortcutsModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+KeyboardShortcutsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default KeyboardShortcutsModal;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css
new file mode 100644
index 000000000..4425e0e0d
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css
@@ -0,0 +1,15 @@
+.shortcut {
+ display: flex;
+ justify-content: space-between;
+ padding: 5px 20px;
+ font-size: 18px;
+}
+
+.key {
+ padding: 2px 4px;
+ border-radius: 3px;
+ background-color: $defaultColor;
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);
+ color: $white;
+ font-size: 16px;
+}
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js
new file mode 100644
index 000000000..9c07e047c
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { shortcuts } from 'Components/keyboardShortcuts';
+import Button from 'Components/Link/Button';
+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 styles from './KeyboardShortcutsModalContent.css';
+
+function getShortcuts() {
+ const allShortcuts = [];
+
+ Object.keys(shortcuts).forEach((key) => {
+ allShortcuts.push(shortcuts[key]);
+ });
+
+ return allShortcuts;
+}
+
+function getShortcutKey(combo, isOsx) {
+ const comboMatch = combo.match(/(.+?)\+(.)/);
+
+ if (!comboMatch) {
+ return combo;
+ }
+
+ const modifier = comboMatch[1];
+ const key = comboMatch[2];
+ let osModifier = modifier;
+
+ if (modifier === 'mod') {
+ osModifier = isOsx ? 'cmd' : 'ctrl';
+ }
+
+ return `${osModifier} + ${key}`;
+}
+
+function KeyboardShortcutsModalContent(props) {
+ const {
+ isOsx,
+ onModalClose
+ } = props;
+
+ const allShortcuts = getShortcuts();
+
+ return (
+
+
+ Keyboard Shortcuts
+
+
+
+ {
+ allShortcuts.map((shortcut) => {
+ return (
+
+
+ {getShortcutKey(shortcut.key, isOsx)}
+
+
+
+ {shortcut.name}
+
+
+ );
+ })
+ }
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+KeyboardShortcutsModalContent.propTypes = {
+ isOsx: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default KeyboardShortcutsModalContent;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js
new file mode 100644
index 000000000..d80877153
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSystemStatusSelector(),
+ (systemStatus) => {
+ return {
+ isOsx: systemStatus.isOsx
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(KeyboardShortcutsModalContent);
diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css
new file mode 100644
index 000000000..1974cbcb1
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.css
@@ -0,0 +1,65 @@
+.header {
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ height: $headerHeight;
+ background-color: $themeAlternateBlue;
+ color: $white;
+}
+
+.logoContainer {
+ display: flex;
+ align-items: center;
+ flex: 0 0 $sidebarWidth;
+ padding-left: 20px;
+}
+
+.logoLink {
+ line-height: 0;
+}
+
+.logo {
+ width: 32px;
+ height: 32px;
+}
+
+.sidebarToggleContainer {
+ display: none;
+ justify-content: center;
+ flex: 0 0 45px;
+ margin-right: 14px;
+}
+
+.right {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+}
+
+.donate {
+ composes: link from 'Components/Link/Link.css';
+
+ width: 30px;
+ color: $themeRed;
+ text-align: center;
+ line-height: 60px;
+
+ &:hover {
+ color: #9c1f30;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .logoContainer {
+ flex: 0 0 60px;
+ }
+
+ .sidebarToggleContainer {
+ display: flex;
+ }
+
+ .donate {
+ display: none;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js
new file mode 100644
index 000000000..9a1117a49
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SeriesSearchInputConnector from './SeriesSearchInputConnector';
+import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
+import KeyboardShortcutsModal from './KeyboardShortcutsModal';
+import styles from './PageHeader.css';
+
+class PageHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props);
+
+ this.state = {
+ isKeyboardShortcutsModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
+ }
+
+ //
+ // Control
+
+ onOpenKeyboardShortcutsModal = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: true });
+ }
+
+ //
+ // Listeners
+
+ onKeyboardShortcutsModalClose = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onSidebarToggle
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+PageHeader.propTypes = {
+ onSidebarToggle: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(PageHeader);
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
new file mode 100644
index 000000000..e27ad883e
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
@@ -0,0 +1,21 @@
+.menuButton {
+ margin-right: 15px;
+ width: 30px;
+ height: 60px;
+ text-align: center;
+
+ &:hover {
+ color: $themeDarkColor;
+ }
+}
+
+.itemIcon {
+ margin-right: 8px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .menuButton {
+ margin-right: 5px;
+ }
+}
+
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
new file mode 100644
index 000000000..ddeeeef04
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
+import styles from './PageHeaderActionsMenu.css';
+
+function PageHeaderActionsMenu(props) {
+ const {
+ formsAuth,
+ onKeyboardShortcutsPress,
+ onRestartPress,
+ onShutdownPress
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ Keyboard Shortcuts
+
+
+
+
+
+
+ Restart
+
+
+
+
+ Shutdown
+
+
+ {
+ formsAuth &&
+
+ }
+
+ {
+ formsAuth &&
+
+
+ Logout
+
+ }
+
+
+
+ );
+}
+
+PageHeaderActionsMenu.propTypes = {
+ formsAuth: PropTypes.bool.isRequired,
+ onKeyboardShortcutsPress: PropTypes.func.isRequired,
+ onRestartPress: PropTypes.func.isRequired,
+ onShutdownPress: PropTypes.func.isRequired
+};
+
+export default PageHeaderActionsMenu;
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js
new file mode 100644
index 000000000..66d131521
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { restart, shutdown } from 'Store/Actions/systemActions';
+import PageHeaderActionsMenu from './PageHeaderActionsMenu';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return {
+ formsAuth: status.item.authentication === 'forms'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ restart,
+ shutdown
+};
+
+class PageHeaderActionsMenuConnector extends Component {
+
+ //
+ // Listeners
+
+ onRestartPress = () => {
+ this.props.restart();
+ }
+
+ onShutdownPress = () => {
+ this.props.shutdown();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+PageHeaderActionsMenuConnector.propTypes = {
+ restart: PropTypes.func.isRequired,
+ shutdown: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);
diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.css b/frontend/src/Components/Page/Header/SeriesSearchInput.css
new file mode 100644
index 000000000..ce9a92271
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchInput.css
@@ -0,0 +1,96 @@
+.wrapper {
+ display: flex;
+ align-items: center;
+}
+
+.input {
+ margin-left: 8px;
+ width: 200px;
+ border: none;
+ border-bottom: solid 1px $white;
+ border-radius: 0;
+ background-color: transparent;
+ box-shadow: none;
+ color: $white;
+ transition: border 0.3s ease-out;
+
+ &::placeholder {
+ color: $white;
+ transition: color 0.3s ease-out;
+ }
+
+ &:focus {
+ outline: 0;
+ border-bottom-color: transparent;
+
+ &::placeholder {
+ color: transparent;
+ }
+ }
+}
+
+.container {
+ position: relative;
+ flex-grow: 1;
+}
+
+.seriesContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.containerOpen {
+ .seriesContainer {
+ position: absolute;
+ top: 42px;
+ z-index: 1;
+ overflow-y: auto;
+ min-width: 100%;
+ max-height: 230px;
+ border: 1px solid $themeDarkColor;
+ border-radius: 4px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ background-color: $themeDarkColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ color: $menuItemColor;
+ }
+}
+
+.list {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.listItem {
+ padding: 0 16px;
+ white-space: nowrap;
+}
+
+.highlighted {
+ background-color: $themeLightColor;
+}
+
+.sectionTitle {
+ padding: 5px 8px;
+ color: $disabledColor;
+}
+
+.addNewSeriesSuggestion {
+ padding: 0 3px;
+ cursor: pointer;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .input {
+ min-width: 150px;
+ max-width: 200px;
+ }
+
+ .container {
+ min-width: 0;
+ max-width: 200px;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.js b/frontend/src/Components/Page/Header/SeriesSearchInput.js
new file mode 100644
index 000000000..44c75a49a
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchInput.js
@@ -0,0 +1,260 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import jdu from 'jdu';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import SeriesSearchResult from './SeriesSearchResult';
+import styles from './SeriesSearchInput.css';
+
+const ADD_NEW_TYPE = 'addNew';
+
+class SeriesSearchInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._autosuggest = null;
+
+ this.state = {
+ value: '',
+ suggestions: []
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.SERIES_SEARCH_INPUT.key, this.focusInput);
+ }
+
+ //
+ // Control
+
+ setAutosuggestRef = (ref) => {
+ this._autosuggest = ref;
+ }
+
+ focusInput = (event) => {
+ event.preventDefault();
+ this._autosuggest.input.focus();
+ }
+
+ getSectionSuggestions(section) {
+ return section.suggestions;
+ }
+
+ renderSectionTitle(section) {
+ return (
+
+ {section.title}
+
+ );
+ }
+
+ getSuggestionValue({ title }) {
+ return title;
+ }
+
+ renderSuggestion(item, { query }) {
+ if (item.type === ADD_NEW_TYPE) {
+ return (
+
+ Search for {query}
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ goToSeries(series) {
+ this.setState({ value: '' });
+ this.props.onGoToSeries(series.titleSlug);
+ }
+
+ reset() {
+ this.setState({
+ value: '',
+ suggestions: []
+ });
+ }
+
+ //
+ // Listeners
+
+ onChange = (event, { newValue, method }) => {
+ if (method === 'up' || method === 'down') {
+ return;
+ }
+
+ this.setState({ value: newValue });
+ }
+
+ onKeyDown = (event) => {
+ if (event.key !== 'Tab' && event.key !== 'Enter') {
+ return;
+ }
+
+ const {
+ suggestions,
+ value
+ } = this.state;
+
+ const {
+ highlightedSectionIndex,
+ highlightedSuggestionIndex
+ } = this._autosuggest.state;
+
+ if (!suggestions.length || highlightedSectionIndex) {
+ this.props.onGoToAddNewSeries(value);
+ this._autosuggest.input.blur();
+
+ return;
+ }
+
+ // If an suggestion is not selected go to the first series,
+ // otherwise go to the selected series.
+
+ if (highlightedSuggestionIndex == null) {
+ this.goToSeries(suggestions[0]);
+ } else {
+ this.goToSeries(suggestions[highlightedSuggestionIndex]);
+ }
+ }
+
+ onBlur = () => {
+ this.reset();
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const lowerCaseValue = jdu.replace(value).toLowerCase();
+
+ const suggestions = this.props.series.filter((series) => {
+ // Check the title first and if there isn't a match fallback to
+ // the alternate titles and finally the tags.
+
+ if (value.length === 1) {
+ return (
+ series.cleanTitle.startsWith(lowerCaseValue) ||
+ series.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.startsWith(lowerCaseValue)) ||
+ series.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue))
+ );
+ }
+
+ return (
+ series.cleanTitle.contains(lowerCaseValue) ||
+ series.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) ||
+ series.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue))
+ );
+ });
+
+ this.setState({ suggestions });
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.setState({
+ suggestions: []
+ });
+ }
+
+ onSuggestionSelected = (event, { suggestion }) => {
+ if (suggestion.type === ADD_NEW_TYPE) {
+ this.props.onGoToAddNewSeries(this.state.value);
+ } else {
+ this.goToSeries(suggestion);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const suggestionGroups = [];
+
+ if (suggestions.length) {
+ suggestionGroups.push({
+ title: 'Existing Series',
+ suggestions
+ });
+ }
+
+ suggestionGroups.push({
+ title: 'Add New Series',
+ suggestions: [
+ {
+ type: ADD_NEW_TYPE,
+ title: value
+ }
+ ]
+ });
+
+ const inputProps = {
+ ref: this.setInputRef,
+ className: styles.input,
+ name: 'seriesSearch',
+ value,
+ placeholder: 'Search',
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onChange,
+ onKeyDown: this.onKeyDown,
+ onBlur: this.onBlur,
+ onFocus: this.onFocus
+ };
+
+ const theme = {
+ container: styles.container,
+ containerOpen: styles.containerOpen,
+ suggestionsContainer: styles.seriesContainer,
+ suggestionsList: styles.list,
+ suggestion: styles.listItem,
+ suggestionHighlighted: styles.highlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+SeriesSearchInput.propTypes = {
+ series: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onGoToSeries: PropTypes.func.isRequired,
+ onGoToAddNewSeries: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(SeriesSearchInput);
diff --git a/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js
new file mode 100644
index 000000000..c90a49330
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js
@@ -0,0 +1,97 @@
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import { createSelector } from 'reselect';
+import jdu from 'jdu';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import SeriesSearchInput from './SeriesSearchInput';
+
+function createCleanTagsSelector() {
+ return createSelector(
+ createTagsSelector(),
+ (tags) => {
+ return tags.map((tag) => {
+ const {
+ id,
+ label
+ } = tag;
+
+ return {
+ id,
+ label,
+ cleanLabel: jdu.replace(label).toLowerCase()
+ };
+ });
+ }
+ );
+}
+
+function createCleanSeriesSelector() {
+ return createSelector(
+ createAllSeriesSelector(),
+ createCleanTagsSelector(),
+ (allSeries, allTags) => {
+ return allSeries.map((series) => {
+ const {
+ title,
+ titleSlug,
+ sortTitle,
+ images,
+ alternateTitles = [],
+ tags = []
+ } = series;
+
+ return {
+ title,
+ titleSlug,
+ sortTitle,
+ images,
+ cleanTitle: jdu.replace(title).toLowerCase(),
+ alternateTitles: alternateTitles.map((alternateTitle) => {
+ return {
+ title: alternateTitle.title,
+ cleanTitle: jdu.replace(alternateTitle.title).toLowerCase()
+ };
+ }),
+ tags: tags.map((id) => {
+ return allTags.find((tag) => tag.id === id);
+ })
+ };
+ }).sort((a, b) => {
+ if (a.cleanTitle < b.cleanTitle) {
+ return -1;
+ }
+ if (a.cleanTitle > b.cleanTitle) {
+ return 1;
+ }
+
+ return 0;
+ });
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createCleanSeriesSelector(),
+ (series) => {
+ return {
+ series
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onGoToSeries(titleSlug) {
+ dispatch(push(`${window.Sonarr.urlBase}/series/${titleSlug}`));
+ },
+
+ onGoToAddNewSeries(query) {
+ dispatch(push(`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesSearchInput);
diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.css b/frontend/src/Components/Page/Header/SeriesSearchResult.css
new file mode 100644
index 000000000..29edc382b
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchResult.css
@@ -0,0 +1,38 @@
+.result {
+ display: flex;
+ padding: 3px;
+ cursor: pointer;
+}
+
+.poster {
+ width: 35px;
+ height: 50px;
+}
+
+.titles {
+ flex: 1 1 1px;
+}
+
+.title {
+ flex: 1 1 1px;
+ margin-left: 5px;
+}
+
+.alternateTitle {
+ composes: title;
+
+ color: $disabledColor;
+ font-size: $smallFontSize;
+}
+
+.tagContainer {
+ composes: title;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .titles,
+ .title,
+ .alternateTitle {
+ @add-mixin truncate;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.js b/frontend/src/Components/Page/Header/SeriesSearchResult.js
new file mode 100644
index 000000000..9e2adb82c
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchResult.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import SeriesPoster from 'Series/SeriesPoster';
+import styles from './SeriesSearchResult.css';
+
+function findMatchingAlternateTitle(alternateTitles, cleanQuery) {
+ return alternateTitles.find((alternateTitle) => {
+ return alternateTitle.cleanTitle.contains(cleanQuery);
+ });
+}
+
+function getMatchingTag(tags, cleanQuery) {
+ return tags.find((tag) => {
+ return tag.cleanLabel.contains(cleanQuery);
+ });
+}
+
+function SeriesSearchResult(props) {
+ const {
+ cleanQuery,
+ title,
+ cleanTitle,
+ images,
+ alternateTitles,
+ tags
+ } = props;
+
+ const titleContains = cleanTitle.contains(cleanQuery);
+ let alternateTitle = null;
+ let tag = null;
+
+ if (!titleContains) {
+ alternateTitle = findMatchingAlternateTitle(alternateTitles, cleanQuery);
+ }
+
+ if (!titleContains && !alternateTitle) {
+ tag = getMatchingTag(tags, cleanQuery);
+ }
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {
+ !!alternateTitle &&
+
+ {alternateTitle.title}
+
+ }
+
+ {
+ !!tag &&
+
+
+ {tag.label}
+
+
+ }
+
+
+ );
+}
+
+SeriesSearchResult.propTypes = {
+ cleanQuery: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ cleanTitle: PropTypes.string.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default SeriesSearchResult;
diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css
new file mode 100644
index 000000000..dd5852e61
--- /dev/null
+++ b/frontend/src/Components/Page/LoadingPage.css
@@ -0,0 +1,3 @@
+.page {
+ composes: page from './Page.css';
+}
diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js
new file mode 100644
index 000000000..398b70c4b
--- /dev/null
+++ b/frontend/src/Components/Page/LoadingPage.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import LoadingMessage from 'Components/Loading/LoadingMessage';
+import styles from './LoadingPage.css';
+
+function LoadingPage() {
+ return (
+
+
+
+
+ );
+}
+
+export default LoadingPage;
diff --git a/frontend/src/Components/Page/Page.css b/frontend/src/Components/Page/Page.css
new file mode 100644
index 000000000..9facbfc22
--- /dev/null
+++ b/frontend/src/Components/Page/Page.css
@@ -0,0 +1,18 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.main {
+ position: relative; /* need this to position inner content - is this really needed? */
+ display: flex;
+ flex: 1 1 auto;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .page {
+ flex-grow: 1;
+ height: initial;
+ }
+}
diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js
new file mode 100644
index 000000000..fc7502fd4
--- /dev/null
+++ b/frontend/src/Components/Page/Page.js
@@ -0,0 +1,130 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import locationShape from 'Helpers/Props/Shapes/locationShape';
+import SignalRConnector from 'Components/SignalRConnector';
+import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
+import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
+import PageHeader from './Header/PageHeader';
+import PageSidebar from './Sidebar/PageSidebar';
+import styles from './Page.css';
+
+class Page extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isUpdatedModalOpen: false,
+ isConnectionLostModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ window.addEventListener('resize', this.onResize);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDisconnected,
+ isUpdated
+ } = this.props;
+
+ if (!prevProps.isUpdated && isUpdated) {
+ this.setState({ isUpdatedModalOpen: true });
+ }
+
+ if (prevProps.isDisconnected !== isDisconnected) {
+ this.setState({ isConnectionLostModalOpen: isDisconnected });
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.onResize);
+ }
+
+ //
+ // Listeners
+
+ onResize = () => {
+ this.props.onResize({
+ width: window.innerWidth,
+ height: window.innerHeight
+ });
+ }
+
+ onUpdatedModalClose = () => {
+ this.setState({ isUpdatedModalOpen: false });
+ }
+
+ onConnectionLostModalClose = () => {
+ this.setState({ isConnectionLostModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ location,
+ children,
+ isSmallScreen,
+ isSidebarVisible,
+ onSidebarToggle,
+ onSidebarVisibleChange
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+Page.propTypes = {
+ className: PropTypes.string,
+ location: locationShape.isRequired,
+ children: PropTypes.node.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ isSidebarVisible: PropTypes.bool.isRequired,
+ isUpdated: PropTypes.bool.isRequired,
+ isDisconnected: PropTypes.bool.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onSidebarToggle: PropTypes.func.isRequired,
+ onSidebarVisibleChange: PropTypes.func.isRequired
+};
+
+Page.defaultProps = {
+ className: styles.page
+};
+
+export default Page;
diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js
new file mode 100644
index 000000000..5085d139b
--- /dev/null
+++ b/frontend/src/Components/Page/PageConnector.js
@@ -0,0 +1,194 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
+import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
+import { fetchSeries } from 'Store/Actions/seriesActions';
+import { fetchTags } from 'Store/Actions/tagActions';
+import { fetchQualityProfiles, fetchLanguageProfiles, fetchUISettings } from 'Store/Actions/settingsActions';
+import { fetchStatus } from 'Store/Actions/systemActions';
+import ErrorPage from './ErrorPage';
+import LoadingPage from './LoadingPage';
+import Page from './Page';
+
+function testLocalStorage() {
+ const key = 'sonarrTest';
+
+ try {
+ localStorage.setItem(key, key);
+ localStorage.removeItem(key);
+
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.series,
+ (state) => state.customFilters,
+ (state) => state.tags,
+ (state) => state.settings,
+ (state) => state.app,
+ createDimensionsSelector(),
+ (series, customFilters, tags, settings, app, dimensions) => {
+ const isPopulated = (
+ series.isPopulated &&
+ customFilters.isPopulated &&
+ tags.isPopulated &&
+ settings.qualityProfiles.isPopulated &&
+ settings.ui.isPopulated
+ );
+
+ const hasError = !!(
+ series.error ||
+ customFilters.error ||
+ tags.error ||
+ settings.qualityProfiles.error ||
+ settings.languageProfiles.error ||
+ settings.ui.error
+ );
+
+ return {
+ isPopulated,
+ hasError,
+ seriesError: series.error,
+ customFiltersError: tags.error,
+ tagsError: tags.error,
+ qualityProfilesError: settings.qualityProfiles.error,
+ uiSettingsError: settings.ui.error,
+ isSmallScreen: dimensions.isSmallScreen,
+ isSidebarVisible: app.isSidebarVisible,
+ version: app.version,
+ isUpdated: app.isUpdated,
+ isDisconnected: app.isDisconnected
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchSeries() {
+ dispatch(fetchSeries());
+ },
+ dispatchFetchCustomFilters() {
+ dispatch(fetchCustomFilters());
+ },
+ dispatchFetchTags() {
+ dispatch(fetchTags());
+ },
+ dispatchFetchQualityProfiles() {
+ dispatch(fetchQualityProfiles());
+ },
+ dispatchFetchLanguageProfiles() {
+ dispatch(fetchLanguageProfiles());
+ },
+ dispatchFetchUISettings() {
+ dispatch(fetchUISettings());
+ },
+ dispatchFetchStatus() {
+ dispatch(fetchStatus());
+ },
+ onResize(dimensions) {
+ dispatch(saveDimensions(dimensions));
+ },
+ onSidebarVisibleChange(isSidebarVisible) {
+ dispatch(setIsSidebarVisible({ isSidebarVisible }));
+ }
+ };
+}
+
+class PageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isLocalStorageSupported: testLocalStorage()
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchSeries();
+ this.props.dispatchFetchCustomFilters();
+ this.props.dispatchFetchTags();
+ this.props.dispatchFetchQualityProfiles();
+ this.props.dispatchFetchLanguageProfiles();
+ this.props.dispatchFetchUISettings();
+ this.props.dispatchFetchStatus();
+ }
+ }
+
+ //
+ // Listeners
+
+ onSidebarToggle = () => {
+ this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isPopulated,
+ hasError,
+ dispatchFetchSeries,
+ dispatchFetchTags,
+ dispatchFetchQualityProfiles,
+ dispatchFetchLanguageProfiles,
+ dispatchFetchUISettings,
+ dispatchFetchStatus,
+ ...otherProps
+ } = this.props;
+
+ if (hasError || !this.state.isLocalStorageSupported) {
+ return (
+
+ );
+ }
+
+ if (isPopulated) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+PageConnector.propTypes = {
+ isPopulated: PropTypes.bool.isRequired,
+ hasError: PropTypes.bool.isRequired,
+ isSidebarVisible: PropTypes.bool.isRequired,
+ dispatchFetchSeries: PropTypes.func.isRequired,
+ dispatchFetchCustomFilters: PropTypes.func.isRequired,
+ dispatchFetchTags: PropTypes.func.isRequired,
+ dispatchFetchQualityProfiles: PropTypes.func.isRequired,
+ dispatchFetchLanguageProfiles: PropTypes.func.isRequired,
+ dispatchFetchUISettings: PropTypes.func.isRequired,
+ dispatchFetchStatus: PropTypes.func.isRequired,
+ onSidebarVisibleChange: PropTypes.func.isRequired
+};
+
+export default withRouter(
+ connect(createMapStateToProps, createMapDispatchToProps)(PageConnector)
+);
diff --git a/frontend/src/Components/Page/PageContent.css b/frontend/src/Components/Page/PageContent.css
new file mode 100644
index 000000000..4580077c3
--- /dev/null
+++ b/frontend/src/Components/Page/PageContent.css
@@ -0,0 +1,8 @@
+.content {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow-x: hidden;
+ width: 100%;
+}
diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js
new file mode 100644
index 000000000..62003c2b0
--- /dev/null
+++ b/frontend/src/Components/Page/PageContent.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import ErrorBoundary from 'Components/Error/ErrorBoundary';
+import PageContentError from './PageContentError';
+import styles from './PageContent.css';
+
+function PageContent(props) {
+ const {
+ className,
+ title,
+ children
+ } = props;
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+PageContent.propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageContent.defaultProps = {
+ className: styles.content
+};
+
+export default PageContent;
diff --git a/frontend/src/Components/Page/PageContentBody.css b/frontend/src/Components/Page/PageContentBody.css
new file mode 100644
index 000000000..8b41754dd
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBody.css
@@ -0,0 +1,19 @@
+.contentBody {
+ /* 1px for flex-basis so the div grows correctly in Edge/Firefox */
+ flex: 1 0 1px;
+}
+
+.innerContentBody {
+ padding: $pageContentBodyPadding;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentBody {
+ flex-basis: auto;
+ overflow-y: hidden !important;
+ }
+
+ .innerContentBody {
+ padding: $pageContentBodyPaddingSmallScreen;
+ }
+}
diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js
new file mode 100644
index 000000000..81bd9b29b
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBody.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import OverlayScroller from 'Components/Scroller/OverlayScroller';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './PageContentBody.css';
+
+class PageContentBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ innerClassName,
+ isSmallScreen,
+ children,
+ dispatch,
+ ...otherProps
+ } = this.props;
+
+ const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+PageContentBody.propTypes = {
+ className: PropTypes.string,
+ innerClassName: PropTypes.string,
+ isSmallScreen: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ dispatch: PropTypes.func
+};
+
+PageContentBody.defaultProps = {
+ className: styles.contentBody,
+ innerClassName: styles.innerContentBody
+};
+
+export default PageContentBody;
diff --git a/frontend/src/Components/Page/PageContentBodyConnector.js b/frontend/src/Components/Page/PageContentBodyConnector.js
new file mode 100644
index 000000000..b5cdfbb21
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBodyConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import PageContentBody from './PageContentBody';
+
+function createMapStateToProps() {
+ return createSelector(
+ createDimensionsSelector(),
+ (dimensions) => {
+ return {
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(PageContentBody);
diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css
new file mode 100644
index 000000000..7b1f7a6db
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentError.css
@@ -0,0 +1,3 @@
+.content {
+ composes: content from './PageContent.css';
+}
diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.js
new file mode 100644
index 000000000..5ae41a936
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentError.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
+import PageContentBodyConnector from './PageContentBodyConnector';
+import styles from './PageContentError.css';
+
+function PageContentError(props) {
+ return (
+
+ );
+}
+
+export default PageContentError;
diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css
new file mode 100644
index 000000000..74bdb3811
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentFooter.css
@@ -0,0 +1,26 @@
+.contentFooter {
+ display: flex;
+ flex: 0 0 auto;
+ padding: 20px;
+ background-color: #f1f1f1;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentFooter {
+ display: block;
+
+ div {
+ margin-top: 10px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .contentFooter {
+ flex-wrap: wrap;
+ }
+}
diff --git a/frontend/src/Components/Page/PageContentFooter.js b/frontend/src/Components/Page/PageContentFooter.js
new file mode 100644
index 000000000..1f6e2d21a
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentFooter.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './PageContentFooter.css';
+
+class PageContentFooter extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+PageContentFooter.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageContentFooter.defaultProps = {
+ className: styles.contentFooter
+};
+
+export default PageContentFooter;
diff --git a/frontend/src/Components/Page/PageJumpBar.css b/frontend/src/Components/Page/PageJumpBar.css
new file mode 100644
index 000000000..9a116fb54
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBar.css
@@ -0,0 +1,22 @@
+.jumpBar {
+ display: flex;
+ align-content: stretch;
+ align-items: stretch;
+ align-self: stretch;
+ justify-content: center;
+ flex: 0 0 30px;
+}
+
+.jumpBarItems {
+ display: flex;
+ justify-content: space-around;
+ flex: 0 0 100%;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .jumpBar {
+ display: none;
+ }
+}
diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js
new file mode 100644
index 000000000..f7d44ae9a
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBar.js
@@ -0,0 +1,133 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import dimensions from 'Styles/Variables/dimensions';
+import Measure from 'Components/Measure';
+import PageJumpBarItem from './PageJumpBarItem';
+import styles from './PageJumpBar.css';
+
+const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
+
+class PageJumpBar extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ height: 0,
+ visibleItems: props.items
+ };
+ }
+
+ componentDidMount() {
+ this.computeVisibleItems();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (
+ prevProps.items !== this.props.items ||
+ prevState.height !== this.state.height
+ ) {
+ this.computeVisibleItems();
+ }
+ }
+
+ //
+ // Control
+
+ computeVisibleItems() {
+ const {
+ items,
+ minimumItems
+ } = this.props;
+
+ const height = this.state.height;
+ const maximumItems = Math.floor(height / ITEM_HEIGHT);
+ const diff = items.length - maximumItems;
+
+ if (diff < 0) {
+ this.setState({ visibleItems: items });
+ return;
+ }
+
+ if (items.length < minimumItems) {
+ this.setState({ visibleItems: items });
+ return;
+ }
+
+ const removeDiff = Math.ceil(items.length / maximumItems);
+
+ const visibleItems = _.reduce(items, (acc, item, index) => {
+ if (index % removeDiff === 0) {
+ acc.push(item);
+ }
+
+ return acc;
+ }, []);
+
+ this.setState({ visibleItems });
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ height }) => {
+ this.setState({ height });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ minimumItems,
+ onItemPress
+ } = this.props;
+
+ const {
+ visibleItems
+ } = this.state;
+
+ if (!visibleItems.length || visibleItems.length < minimumItems) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ visibleItems.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+PageJumpBar.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.string).isRequired,
+ minimumItems: PropTypes.number.isRequired,
+ onItemPress: PropTypes.func.isRequired
+};
+
+PageJumpBar.defaultProps = {
+ minimumItems: 5
+};
+
+export default PageJumpBar;
diff --git a/frontend/src/Components/Page/PageJumpBarItem.css b/frontend/src/Components/Page/PageJumpBarItem.css
new file mode 100644
index 000000000..e829dd31a
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBarItem.css
@@ -0,0 +1,14 @@
+.jumpBarItem {
+ flex: 1 0 $jumpBarItemHeight;
+ border-bottom: 1px solid $borderColor;
+ text-align: center;
+ font-weight: bold;
+
+ &:hover {
+ color: #777;
+ }
+
+ &:last-child {
+ border: none;
+ }
+}
diff --git a/frontend/src/Components/Page/PageJumpBarItem.js b/frontend/src/Components/Page/PageJumpBarItem.js
new file mode 100644
index 000000000..aeffe4ddd
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBarItem.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './PageJumpBarItem.css';
+
+class PageJumpBarItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ label,
+ onItemPress
+ } = this.props;
+
+ onItemPress(label);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ {this.props.label.toUpperCase()}
+
+ );
+ }
+}
+
+PageJumpBarItem.propTypes = {
+ label: PropTypes.string.isRequired,
+ onItemPress: PropTypes.func.isRequired
+};
+
+export default PageJumpBarItem;
diff --git a/frontend/src/Components/Page/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js
new file mode 100644
index 000000000..774b88669
--- /dev/null
+++ b/frontend/src/Components/Page/PageSectionContent.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+
+function PageSectionContent(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ errorMessage,
+ children
+ } = props;
+
+ if (isFetching) {
+ return (
+
+ );
+ } else if (!isFetching && !!error) {
+ return (
+ {errorMessage}
+ );
+ } else if (isPopulated && !error) {
+ return (
+ {children}
+ );
+ }
+
+ return null;
+}
+
+PageSectionContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ errorMessage: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired
+};
+
+export default PageSectionContent;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.css b/frontend/src/Components/Page/Sidebar/Messages/Message.css
new file mode 100644
index 000000000..7d53adb69
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Message.css
@@ -0,0 +1,42 @@
+.message {
+ display: flex;
+ border-left: 3px solid $infoColor;
+}
+
+.iconContainer,
+.text {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ padding: 2px 0;
+ color: $sidebarColor;
+}
+
+.iconContainer {
+ flex: 0 0 25px;
+ margin-left: 24px;
+ padding: 10px 0;
+}
+
+.text {
+ margin-right: 24px;
+ font-size: 13px;
+}
+
+/* Types */
+
+.error {
+ border-left-color: $dangerColor;
+}
+
+.info {
+ border-left-color: $infoColor;
+}
+
+.success {
+ border-left-color: $successColor;
+}
+
+.warning {
+ border-left-color: $warningColor;
+}
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.js b/frontend/src/Components/Page/Sidebar/Messages/Message.js
new file mode 100644
index 000000000..7f2343d7c
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import styles from './Message.css';
+
+function getIconName(name) {
+ switch (name) {
+ case 'ApplicationUpdate':
+ return icons.RESTART;
+ case 'Backup':
+ return icons.BACKUP;
+ case 'CheckHealth':
+ return icons.HEALTH;
+ case 'EpisodeSearch':
+ return icons.SEARCH;
+ case 'Housekeeping':
+ return icons.HOUSEKEEPING;
+ case 'RefreshSeries':
+ return icons.REFRESH;
+ case 'RssSync':
+ return icons.RSS;
+ case 'SeasonSearch':
+ return icons.SEARCH;
+ case 'SeriesSearch':
+ return icons.SEARCH;
+ case 'UpdateSceneMapping':
+ return icons.REFRESH;
+ default:
+ return icons.SPINNER;
+ }
+}
+
+function Message(props) {
+ const {
+ name,
+ message,
+ type
+ } = props;
+
+ return (
+
+
+
+
+
+
+ {message}
+
+
+ );
+}
+
+Message.propTypes = {
+ name: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired
+};
+
+export default Message;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js
new file mode 100644
index 000000000..06c545c27
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { hideMessage } from 'Store/Actions/appActions';
+import Message from './Message';
+
+const mapDispatchToProps = {
+ hideMessage
+};
+
+class MessageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._hideTimeoutId = null;
+ this.scheduleHideMessage(props.hideAfter);
+ }
+
+ componentDidUpdate() {
+ this.scheduleHideMessage(this.props.hideAfter);
+ }
+
+ //
+ // Control
+
+ scheduleHideMessage = (hideAfter) => {
+ if (this._hideTimeoutId) {
+ clearTimeout(this._hideTimeoutId);
+ }
+
+ if (hideAfter) {
+ this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000);
+ }
+ }
+
+ hideMessage = () => {
+ this.props.hideMessage({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MessageConnector.propTypes = {
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ hideAfter: PropTypes.number.isRequired,
+ hideMessage: PropTypes.func.isRequired
+};
+
+MessageConnector.defaultProps = {
+ // Hide messages after 60 seconds if there is no activity
+ // hideAfter: 60
+};
+
+export default connect(undefined, mapDispatchToProps)(MessageConnector);
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.css b/frontend/src/Components/Page/Sidebar/Messages/Messages.css
new file mode 100644
index 000000000..ef01ad02c
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.css
@@ -0,0 +1,11 @@
+.messages {
+ margin-top: auto;
+ margin-bottom: 20px;
+ padding-top: 20px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .messages {
+ margin-bottom: 0;
+ }
+}
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.js b/frontend/src/Components/Page/Sidebar/Messages/Messages.js
new file mode 100644
index 000000000..ec8876f6e
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import MessageConnector from './MessageConnector';
+import styles from './Messages.css';
+
+function Messages({ messages }) {
+ return (
+
+ {
+ messages.map((message) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+Messages.propTypes = {
+ messages: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Messages;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js
new file mode 100644
index 000000000..5d20d9194
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import Messages from './Messages';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.messages.items,
+ (messages) => {
+ return {
+ messages: messages.slice().reverse()
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Messages);
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.css b/frontend/src/Components/Page/Sidebar/PageSidebar.css
new file mode 100644
index 000000000..fdbd80320
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.css
@@ -0,0 +1,34 @@
+.sidebarContainer {
+ flex: 0 0 $sidebarWidth;
+ overflow: hidden;
+ width: $sidebarWidth;
+ background-color: $sidebarBackgroundColor;
+ transition: transform 300ms ease-in-out;
+ transform: translateX(0);
+}
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: $sidebarBackgroundColor;
+ color: $white;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .sidebarContainer {
+ position: fixed;
+ top: 0;
+ z-index: 2;
+ height: 100vh;
+ }
+
+ .sidebar {
+ position: fixed;
+ z-index: 2;
+ overflow-y: auto;
+ width: 100%;
+ height: 100%;
+ }
+}
+
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js
new file mode 100644
index 000000000..6ffdf53cc
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js
@@ -0,0 +1,525 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import locationShape from 'Helpers/Props/Shapes/locationShape';
+import dimensions from 'Styles/Variables/dimensions';
+import OverlayScroller from 'Components/Scroller/OverlayScroller';
+import Scroller from 'Components/Scroller/Scroller';
+import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector';
+import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector';
+import MessagesConnector from './Messages/MessagesConnector';
+import PageSidebarItem from './PageSidebarItem';
+import styles from './PageSidebar.css';
+
+const HEADER_HEIGHT = parseInt(dimensions.headerHeight);
+const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
+
+const links = [
+ {
+ iconName: icons.SERIES_CONTINUING,
+ title: 'Series',
+ to: '/',
+ alias: '/series',
+ children: [
+ {
+ title: 'Add New',
+ to: '/add/new'
+ },
+ {
+ title: 'Import',
+ to: '/add/import'
+ },
+ {
+ title: 'Mass Editor',
+ to: '/serieseditor'
+ },
+ {
+ title: 'Season Pass',
+ to: '/seasonpass'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.CALENDAR,
+ title: 'Calendar',
+ to: '/calendar'
+ },
+
+ {
+ iconName: icons.ACTIVITY,
+ title: 'Activity',
+ to: '/activity/queue',
+ children: [
+ {
+ title: 'Queue',
+ to: '/activity/queue',
+ statusComponent: QueueStatusConnector
+ },
+ {
+ title: 'History',
+ to: '/activity/history'
+ },
+ {
+ title: 'Blacklist',
+ to: '/activity/blacklist'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.WARNING,
+ title: 'Wanted',
+ to: '/wanted/missing',
+ children: [
+ {
+ title: 'Missing',
+ to: '/wanted/missing'
+ },
+ {
+ title: 'Cutoff Unmet',
+ to: '/wanted/cutoffunmet'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.SETTINGS,
+ title: 'Settings',
+ to: '/settings',
+ children: [
+ {
+ title: 'Media Management',
+ to: '/settings/mediamanagement'
+ },
+ {
+ title: 'Profiles',
+ to: '/settings/profiles'
+ },
+ {
+ title: 'Quality',
+ to: '/settings/quality'
+ },
+ {
+ title: 'Indexers',
+ to: '/settings/indexers'
+ },
+ {
+ title: 'Download Clients',
+ to: '/settings/downloadclients'
+ },
+ {
+ title: 'Connect',
+ to: '/settings/connect'
+ },
+ {
+ title: 'Metadata',
+ to: '/settings/metadata'
+ },
+ {
+ title: 'Tags',
+ to: '/settings/tags'
+ },
+ {
+ title: 'General',
+ to: '/settings/general'
+ },
+ {
+ title: 'UI',
+ to: '/settings/ui'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.SYSTEM,
+ title: 'System',
+ to: '/system/status',
+ children: [
+ {
+ title: 'Status',
+ to: '/system/status',
+ statusComponent: HealthStatusConnector
+ },
+ {
+ title: 'Tasks',
+ to: '/system/tasks'
+ },
+ {
+ title: 'Backup',
+ to: '/system/backup'
+ },
+ {
+ title: 'Updates',
+ to: '/system/updates'
+ },
+ {
+ title: 'Events',
+ to: '/system/events'
+ },
+ {
+ title: 'Log Files',
+ to: '/system/logs/files'
+ }
+ ]
+ }
+];
+
+function getActiveParent(pathname) {
+ let activeParent = links[0].to;
+
+ links.forEach((link) => {
+ if (link.to && link.to === pathname) {
+ activeParent = link.to;
+
+ return false;
+ }
+
+ const children = link.children;
+
+ if (children) {
+ children.forEach((childLink) => {
+ if (pathname.startsWith(childLink.to)) {
+ activeParent = link.to;
+
+ return false;
+ }
+ });
+ }
+
+ if (
+ (link.to !== '/' && pathname.startsWith(link.to)) ||
+ (link.alias && pathname.startsWith(link.alias))
+ ) {
+ activeParent = link.to;
+
+ return false;
+ }
+ });
+
+ return activeParent;
+}
+
+function hasActiveChildLink(link, pathname) {
+ const children = link.children;
+
+ if (!children || !children.length) {
+ return false;
+ }
+
+ return _.some(children, (child) => {
+ return child.to === pathname;
+ });
+}
+
+function getPositioning() {
+ const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY;
+ const top = Math.max(HEADER_HEIGHT - windowScroll, 0);
+ const height = window.innerHeight - top;
+
+ return {
+ top: `${top}px`,
+ height: `${height}px`
+ };
+}
+
+class PageSidebar extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._touchStartX = null;
+ this._touchStartY = null;
+ this._sidebarRef = null;
+
+ this.state = {
+ top: dimensions.headerHeight,
+ height: `${window.innerHeight - HEADER_HEIGHT}px`,
+ transition: null,
+ transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
+ };
+ }
+
+ componentDidMount() {
+ if (this.props.isSmallScreen) {
+ window.addEventListener('click', this.onWindowClick, { capture: true });
+ window.addEventListener('scroll', this.onWindowScroll);
+ window.addEventListener('touchstart', this.onTouchStart);
+ window.addEventListener('touchmove', this.onTouchMove);
+ window.addEventListener('touchend', this.onTouchEnd);
+ window.addEventListener('touchcancel', this.onTouchCancel);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSidebarVisible
+ } = this.props;
+
+ const transform = this.state.transform;
+
+ if (prevProps.isSidebarVisible !== isSidebarVisible) {
+ this._setSidebarTransform(isSidebarVisible);
+ } else if (transform === 0 && !isSidebarVisible) {
+ this.props.onSidebarVisibleChange(true);
+ } else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) {
+ this.props.onSidebarVisibleChange(false);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.props.isSmallScreen) {
+ window.removeEventListener('click', this.onWindowClick, { capture: true });
+ window.removeEventListener('scroll', this.onWindowScroll);
+ window.removeEventListener('touchstart', this.onTouchStart);
+ window.removeEventListener('touchmove', this.onTouchMove);
+ window.removeEventListener('touchend', this.onTouchEnd);
+ window.removeEventListener('touchcancel', this.onTouchCancel);
+ }
+ }
+
+ //
+ // Control
+
+ _setSidebarRef = (ref) => {
+ this._sidebarRef = ref;
+ }
+
+ _setSidebarTransform(isSidebarVisible, transition, callback) {
+ this.setState({
+ transition,
+ transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
+ }, callback);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const sidebar = ReactDOM.findDOMNode(this._sidebarRef);
+ const toggleButton = document.getElementById('sidebar-toggle-button');
+
+ if (!sidebar) {
+ return;
+ }
+
+ if (
+ !sidebar.contains(event.target) &&
+ !toggleButton.contains(event.target) &&
+ this.props.isSidebarVisible
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.props.onSidebarVisibleChange(false);
+ }
+ }
+
+ onWindowScroll = () => {
+ this.setState(getPositioning());
+ }
+
+ onTouchStart = (event) => {
+ const touches = event.touches;
+ const touchStartX = touches[0].pageX;
+ const touchStartY = touches[0].pageY;
+ const isSidebarVisible = this.props.isSidebarVisible;
+
+ if (touches.length !== 1) {
+ return;
+ }
+
+ if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) {
+ return;
+ } else if (!isSidebarVisible && touchStartX > 40) {
+ return;
+ }
+
+ this._touchStartX = touchStartX;
+ this._touchStartY = touchStartY;
+ }
+
+ onTouchMove = (event) => {
+ const touches = event.touches;
+ const currentTouchX = touches[0].pageX;
+ // const currentTouchY = touches[0].pageY;
+ // const isSidebarVisible = this.props.isSidebarVisible;
+
+ if (!this._touchStartX) {
+ return;
+ }
+
+ // This is a bit funky when trying to close and you scroll
+ // vertical too much by mistake, commenting out for now.
+ // TODO: Evaluate if this should be nuked
+
+ // if (Math.abs(this._touchStartY - currentTouchY) > 40) {
+ // const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1;
+
+ // this.setState({
+ // transition: 'none',
+ // transform
+ // });
+
+ // return;
+ // }
+
+ if (Math.abs(this._touchStartX - currentTouchX) < 40) {
+ return;
+ }
+
+ const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0);
+
+ this.setState({
+ transition: 'none',
+ transform
+ });
+ }
+
+ onTouchEnd = (event) => {
+ const touches = event.changedTouches;
+ const currentTouch = touches[0].pageX;
+
+ if (!this._touchStartX) {
+ return;
+ }
+
+ if (currentTouch > this._touchStartX && currentTouch > 50) {
+ this._setSidebarTransform(true, 'none');
+ } else if (currentTouch < this._touchStartX && currentTouch < 80) {
+ this._setSidebarTransform(false, 'transform 50ms ease-in-out');
+ } else {
+ this._setSidebarTransform(this.props.isSidebarVisible);
+ }
+
+ this._touchStartX = null;
+ this._touchStartY = null;
+ }
+
+ onTouchCancel = (event) => {
+ this._touchStartX = null;
+ this._touchStartY = null;
+ }
+
+ onItemPress = () => {
+ this.props.onSidebarVisibleChange(false);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ location,
+ isSmallScreen
+ } = this.props;
+
+ const {
+ top,
+ height,
+ transition,
+ transform
+ } = this.state;
+
+ const urlBase = window.Sonarr.urlBase;
+ const pathname = urlBase ? location.pathname.substr(urlBase.length) || '/' : location.pathname;
+ const activeParent = getActiveParent(pathname);
+
+ let containerStyle = {};
+ let sidebarStyle = {};
+
+ if (isSmallScreen) {
+ containerStyle = {
+ transition,
+ transform: `translateX(${transform}px)`
+ };
+
+ sidebarStyle = {
+ top,
+ height
+ };
+ }
+
+ const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
+
+ return (
+
+
+
+ {
+ links.map((link) => {
+ const childWithStatusComponent = _.find(link.children, (child) => {
+ return !!child.statusComponent;
+ });
+
+ const childStatusComponent = childWithStatusComponent ?
+ childWithStatusComponent.statusComponent :
+ null;
+
+ const isActiveParent = activeParent === link.to;
+ const hasActiveChild = hasActiveChildLink(link, pathname);
+
+ return (
+
+ {
+ link.children && link.to === activeParent &&
+ link.children.map((child) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+PageSidebar.propTypes = {
+ location: locationShape.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ isSidebarVisible: PropTypes.bool.isRequired,
+ onSidebarVisibleChange: PropTypes.func.isRequired
+};
+
+export default PageSidebar;
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css
new file mode 100644
index 000000000..dbf0bd2ba
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css
@@ -0,0 +1,50 @@
+.item {
+ border-left: 3px solid transparent;
+ color: $sidebarColor;
+ transition: border-left 0.3s ease-in-out;
+}
+
+.isActiveItem {
+ border-left: 3px solid $themeAlternateBlue;
+}
+
+.link {
+ display: block;
+ padding: 12px 24px;
+ color: $sidebarColor;
+
+ &:hover,
+ &:focus {
+ color: $themeBlue;
+ text-decoration: none;
+ }
+}
+
+.childLink {
+ composes: link;
+
+ padding: 10px 24px;
+}
+
+.isActiveLink {
+ color: $themeAlternateBlue;
+}
+
+.isActiveParentLink {
+ background-color: $sidebarActiveBackgroundColor;
+}
+
+.iconContainer {
+ display: inline-block;
+ margin-right: 7px;
+ width: 18px;
+ text-align: center;
+}
+
+.noIcon {
+ margin-left: 25px;
+}
+
+.status {
+ float: right;
+}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js
new file mode 100644
index 000000000..d494150c0
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { map } from 'Helpers/elementChildren';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './PageSidebarItem.css';
+
+class PageSidebarItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ isChildItem,
+ isParentItem,
+ onPress
+ } = this.props;
+
+ if (isChildItem || !isParentItem) {
+ onPress();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ iconName,
+ title,
+ to,
+ isActive,
+ isActiveParent,
+ isChildItem,
+ statusComponent: StatusComponent,
+ children
+ } = this.props;
+
+ return (
+
+
+ {
+ !!iconName &&
+
+
+
+ }
+
+
+ {title}
+
+
+ {
+ !!StatusComponent &&
+
+
+
+ }
+
+
+ {
+ children &&
+ map(children, (child) => {
+ return React.cloneElement(child, { isChildItem: true });
+ })
+ }
+
+ );
+ }
+}
+
+PageSidebarItem.propTypes = {
+ iconName: PropTypes.object,
+ title: PropTypes.string.isRequired,
+ to: PropTypes.string.isRequired,
+ isActive: PropTypes.bool,
+ isActiveParent: PropTypes.bool,
+ isParentItem: PropTypes.bool.isRequired,
+ isChildItem: PropTypes.bool.isRequired,
+ statusComponent: PropTypes.func,
+ children: PropTypes.node,
+ onPress: PropTypes.func
+};
+
+PageSidebarItem.defaultProps = {
+ isChildItem: false
+};
+
+export default PageSidebarItem;
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css
new file mode 100644
index 000000000..4dd0cc678
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css
@@ -0,0 +1,3 @@
+.status {
+ composes: label from 'Components/Label.css';
+}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js
new file mode 100644
index 000000000..c1ea615ed
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function PageSidebarStatus({ count, errors, warnings }) {
+ if (!count) {
+ return null;
+ }
+
+ let kind = kinds.INFO;
+
+ if (errors) {
+ kind = kinds.DANGER;
+ } else if (warnings) {
+ kind = kinds.WARNING;
+ }
+
+ return (
+
+ {count}
+
+ );
+}
+
+PageSidebarStatus.propTypes = {
+ count: PropTypes.number,
+ errors: PropTypes.bool,
+ warnings: PropTypes.bool
+};
+
+export default PageSidebarStatus;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.css b/frontend/src/Components/Page/Toolbar/PageToolbar.css
new file mode 100644
index 000000000..e040bc884
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbar.css
@@ -0,0 +1,16 @@
+.toolbar {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 auto;
+ padding: 0 20px;
+ height: $toolbarHeight;
+ background-color: $toolbarBackgroundColor;
+ color: $toolbarColor;
+ line-height: 60px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .toolbar {
+ padding: 0 10px;
+ }
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.js b/frontend/src/Components/Page/Toolbar/PageToolbar.js
new file mode 100644
index 000000000..728f1b0d9
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbar.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './PageToolbar.css';
+
+class PageToolbar extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+PageToolbar.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageToolbar.defaultProps = {
+ className: styles.toolbar
+};
+
+export default PageToolbar;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
new file mode 100644
index 000000000..11944c1e9
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
@@ -0,0 +1,32 @@
+.toolbarButton {
+ composes: link from 'Components/Link/Link.css';
+
+ width: $toolbarButtonWidth;
+ text-align: center;
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+
+ &.isDisabled {
+ color: $disabledColor;
+ }
+}
+
+.isDisabled {
+ color: $disabledColor;
+}
+
+.labelContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 16px;
+}
+
+.label {
+ padding: 0 3px;
+ color: $toolbarLabelColor;
+ font-size: $extraSmallFontSize;
+ line-height: calc($extraSmallFontSize + 1px);
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js
new file mode 100644
index 000000000..381046bf5
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js
@@ -0,0 +1,57 @@
+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 './PageToolbarButton.css';
+
+function PageToolbarButton(props) {
+ const {
+ label,
+ iconName,
+ spinningName,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+}
+
+PageToolbarButton.propTypes = {
+ label: PropTypes.string.isRequired,
+ iconName: PropTypes.object.isRequired,
+ spinningName: PropTypes.object,
+ isSpinning: PropTypes.bool,
+ isDisabled: PropTypes.bool
+};
+
+PageToolbarButton.defaultProps = {
+ spinningName: icons.SPINNER,
+ isDisabled: false,
+ isSpinning: false
+};
+
+export default PageToolbarButton;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css
new file mode 100644
index 000000000..5fb56b77c
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css
@@ -0,0 +1,27 @@
+.sectionContainer {
+ display: flex;
+ flex: 1 1 10%;
+ overflow: hidden;
+}
+
+.section {
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+}
+
+.left {
+ justify-content: flex-start;
+}
+
+.center {
+ justify-content: center;
+}
+
+.right {
+ justify-content: flex-end;
+}
+
+.overflowMenuItemIcon {
+ margin-right: 8px;
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js
new file mode 100644
index 000000000..57b53ff4e
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js
@@ -0,0 +1,221 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { forEach } from 'Helpers/elementChildren';
+import { align, icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import SpinnerIcon from 'Components/SpinnerIcon';
+import Measure from 'Components/Measure';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+import styles from './PageToolbarSection.css';
+
+const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
+const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
+const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
+const SEPARATOR_NAME = 'PageToolbarSeparator';
+
+function calculateOverflowItems(children, isMeasured, width, collapseButtons) {
+ let buttonCount = 0;
+ let separatorCount = 0;
+ const validChildren = [];
+
+ forEach(children, (child) => {
+ const name = child.type.name;
+
+ if (name === SEPARATOR_NAME) {
+ separatorCount++;
+ } else {
+ buttonCount++;
+ }
+
+ validChildren.push(child);
+ });
+
+ const buttonsWidth = buttonCount * BUTTON_WIDTH;
+ const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
+ const totalWidth = buttonsWidth + separatorsWidth;
+
+ // If the width of buttons and separators is less than
+ // the available width return all valid children.
+
+ if (
+ !isMeasured ||
+ !collapseButtons ||
+ totalWidth < width
+ ) {
+ return {
+ buttons: validChildren,
+ buttonCount,
+ overflowItems: []
+ };
+ }
+
+ const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1);
+ const buttons = [];
+ const overflowItems = [];
+ let actualButtons = 0;
+
+ // Return all buttons if only one is being pushed to the overflow menu.
+ if (buttonCount - 1 === maxButtons) {
+ return {
+ buttons: validChildren,
+ buttonCount,
+ overflowItems: []
+ };
+ }
+
+ validChildren.forEach((child, index) => {
+ if (actualButtons < maxButtons) {
+ if (child.type.name !== SEPARATOR_NAME) {
+ buttons.push(child);
+ actualButtons++;
+ }
+ } else if (child.type.name !== SEPARATOR_NAME) {
+ overflowItems.push(child.props);
+ }
+ });
+
+ return {
+ buttons,
+ buttonCount,
+ overflowItems
+ };
+}
+
+class PageToolbarSection extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMeasured: false,
+ width: 0,
+ buttons: [],
+ overflowItems: []
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({
+ isMeasured: true,
+ width
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ alignContent,
+ collapseButtons
+ } = this.props;
+
+ const {
+ isMeasured,
+ width
+ } = this.state;
+
+ const {
+ buttons,
+ buttonCount,
+ overflowItems
+ } = calculateOverflowItems(children, isMeasured, width, collapseButtons);
+
+ return (
+
+
+ {
+ isMeasured ?
+
+ {
+ buttons.map((button) => {
+ return button;
+ })
+ }
+
+ {
+ !!overflowItems.length &&
+
+
+
+
+ {
+ overflowItems.map((item) => {
+ const {
+ iconName,
+ spinningName,
+ label,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = item;
+
+ return (
+
+
+ {label}
+
+ );
+ })
+ }
+
+
+ }
+ :
+ null
+ }
+
+
+ );
+ }
+
+}
+
+PageToolbarSection.propTypes = {
+ children: PropTypes.node,
+ alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]),
+ collapseButtons: PropTypes.bool.isRequired
+};
+
+PageToolbarSection.defaultProps = {
+ alignContent: align.LEFT,
+ collapseButtons: true
+};
+
+export default PageToolbarSection;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css
new file mode 100644
index 000000000..968673593
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css
@@ -0,0 +1,12 @@
+.separator {
+ margin: 10px $toolbarSeparatorMargin;
+ height: 40px;
+ border-right: 1px solid #e5e5e5;
+ opacity: 0.35;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .separator {
+ margin: 10px 5px;
+ }
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js
new file mode 100644
index 000000000..754248f99
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js
@@ -0,0 +1,17 @@
+import React, { Component } from 'react';
+import styles from './PageToolbarSeparator.css';
+
+class PageToolbarSeparator extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+
+}
+
+export default PageToolbarSeparator;
diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css
new file mode 100644
index 000000000..2f0019043
--- /dev/null
+++ b/frontend/src/Components/ProgressBar.css
@@ -0,0 +1,93 @@
+.container {
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+ border-radius: 4px;
+ background-color: #f5f5f5;
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.progressBar {
+ position: relative;
+ z-index: 1;
+ float: left;
+ width: 0;
+ height: 100%;
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+ color: $white;
+ transition: width 0.6s ease;
+}
+
+.frontTextContainer {
+ z-index: 1;
+ color: $white;
+}
+
+.backTextContainer,
+.frontTextContainer {
+ position: absolute;
+ overflow: hidden;
+ width: 0;
+ height: 100%;
+}
+
+.backText,
+.frontText {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ font-size: 12px;
+ cursor: default;
+}
+
+.primary {
+ background-color: $primaryColor;
+}
+
+.danger {
+ background-color: $dangerColor;
+}
+
+.success {
+ background-color: $successColor;
+}
+
+.purple {
+ background-color: $purple;
+}
+
+.warning {
+ background-color: $warningColor;
+}
+
+.info {
+ background-color: $infoColor;
+}
+
+.small {
+ height: $progressBarSmallHeight;
+
+ .backText,
+ .frontText {
+ height: $progressBarSmallHeight;
+ }
+}
+
+.medium {
+ height: $progressBarMediumHeight;
+
+ .backText,
+ .frontText {
+ height: $progressBarMediumHeight;
+ }
+}
+
+.large {
+ height: $progressBarLargeHeight;
+
+ .backText,
+ .frontText {
+ height: $progressBarLargeHeight;
+ }
+}
diff --git a/frontend/src/Components/ProgressBar.js b/frontend/src/Components/ProgressBar.js
new file mode 100644
index 000000000..4f457d558
--- /dev/null
+++ b/frontend/src/Components/ProgressBar.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds, sizes } from 'Helpers/Props';
+import styles from './ProgressBar.css';
+
+function ProgressBar(props) {
+ const {
+ className,
+ containerClassName,
+ title,
+ progress,
+ precision,
+ showText,
+ text,
+ kind,
+ size,
+ width
+ } = props;
+
+ const progressPercent = `${progress.toFixed(precision)}%`;
+ const progressText = text || progressPercent;
+ const actualWidth = width ? `${width}px` : '100%';
+
+ return (
+
+ {
+ showText && !!width &&
+
+ }
+
+
+ {
+ showText &&
+
+ }
+
+ );
+}
+
+ProgressBar.propTypes = {
+ className: PropTypes.string,
+ containerClassName: PropTypes.string,
+ title: PropTypes.string,
+ progress: PropTypes.number.isRequired,
+ precision: PropTypes.number.isRequired,
+ showText: PropTypes.bool.isRequired,
+ text: PropTypes.string,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ size: PropTypes.oneOf(sizes.all).isRequired,
+ width: PropTypes.number
+};
+
+ProgressBar.defaultProps = {
+ className: styles.progressBar,
+ containerClassName: styles.container,
+ precision: 1,
+ showText: false,
+ kind: kinds.PRIMARY,
+ size: sizes.MEDIUM
+};
+
+export default ProgressBar;
diff --git a/frontend/src/Components/Router/Switch.js b/frontend/src/Components/Router/Switch.js
new file mode 100644
index 000000000..0c0004a50
--- /dev/null
+++ b/frontend/src/Components/Router/Switch.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Switch as RouterSwitch } from 'react-router-dom';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import { map } from 'Helpers/elementChildren';
+
+class Switch extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+ {
+ map(children, (child) => {
+ const {
+ path: childPath,
+ addUrlBase = true
+ } = child.props;
+
+ if (!childPath) {
+ return child;
+ }
+
+ const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
+
+ return React.cloneElement(child, { path });
+ })
+ }
+
+ );
+ }
+}
+
+Switch.propTypes = {
+ children: PropTypes.node.isRequired
+};
+
+export default Switch;
diff --git a/frontend/src/Components/Scroller/OverlayScroller.css b/frontend/src/Components/Scroller/OverlayScroller.css
new file mode 100644
index 000000000..707a9ac6f
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.css
@@ -0,0 +1,15 @@
+.scroller {
+ /* Placeholder */
+}
+
+.thumb {
+ min-height: 50px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background-color: $scrollbarBackgroundColor;
+ background-clip: padding-box;
+
+ &:hover {
+ background-color: $scrollbarHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/Components/Scroller/OverlayScroller.js b/frontend/src/Components/Scroller/OverlayScroller.js
new file mode 100644
index 000000000..23fe6058a
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Scrollbars } from 'react-custom-scrollbars';
+import { scrollDirections } from 'Helpers/Props';
+import styles from './OverlayScroller.css';
+
+class OverlayScroller extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scroller = null;
+ this._isScrolling = false;
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ scrollTop
+ } = this.props;
+
+ if (!this._isScrolling && scrollTop != null && scrollTop !== prevProps.scrollTop) {
+ this._scroller.scrollTop(scrollTop);
+ }
+ }
+
+ //
+ // Control
+
+ _setScrollRef = (ref) => {
+ this._scroller = ref;
+ }
+
+ _renderThumb = (props) => {
+ return (
+
+ );
+ }
+
+ _renderView = (props) => {
+ return (
+
+ );
+ }
+
+ //
+ // Listers
+
+ onScrollStart = () => {
+ this._isScrolling = true;
+ }
+
+ onScrollStop = () => {
+ this._isScrolling = false;
+ }
+
+ onScroll = (event) => {
+ const {
+ scrollTop,
+ scrollLeft
+ } = event.currentTarget;
+
+ this._isScrolling = true;
+ const onScroll = this.props.onScroll;
+
+ if (onScroll) {
+ onScroll({ scrollTop, scrollLeft });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ autoHide,
+ autoScroll,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+OverlayScroller.propTypes = {
+ className: PropTypes.string,
+ trackClassName: PropTypes.string,
+ scrollTop: PropTypes.number,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
+ autoHide: PropTypes.bool.isRequired,
+ autoScroll: PropTypes.bool.isRequired,
+ children: PropTypes.node,
+ onScroll: PropTypes.func
+};
+
+OverlayScroller.defaultProps = {
+ className: styles.scroller,
+ trackClassName: styles.thumb,
+ scrollDirection: scrollDirections.VERTICAL,
+ autoHide: false,
+ autoScroll: true
+};
+
+export default OverlayScroller;
diff --git a/frontend/src/Components/Scroller/Scroller.css b/frontend/src/Components/Scroller/Scroller.css
new file mode 100644
index 000000000..c8783a8de
--- /dev/null
+++ b/frontend/src/Components/Scroller/Scroller.css
@@ -0,0 +1,28 @@
+.scroller {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.none {
+ overflow-x: hidden;
+ overflow-y: hidden;
+}
+
+.vertical {
+ overflow-x: hidden;
+ overflow-y: scroll;
+
+ &.autoScroll {
+ overflow-y: auto;
+ }
+}
+
+.horizontal {
+ overflow-x: scroll;
+ overflow-y: hidden;
+
+ &.autoScroll {
+ overflow-x: auto;
+ }
+}
diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js
new file mode 100644
index 000000000..701ac0cf4
--- /dev/null
+++ b/frontend/src/Components/Scroller/Scroller.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { scrollDirections } from 'Helpers/Props';
+import styles from './Scroller.css';
+
+class Scroller extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scroller = null;
+ }
+
+ componentDidMount() {
+ const {
+ scrollTop
+ } = this.props;
+
+ if (this.props.scrollTop != null) {
+ this._scroller.scrollTop = scrollTop;
+ }
+ }
+
+ //
+ // Control
+
+ _setScrollerRef = (ref) => {
+ this._scroller = ref;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ scrollDirection,
+ autoScroll,
+ children,
+ scrollTop,
+ onScroll,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+Scroller.propTypes = {
+ className: PropTypes.string,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
+ autoScroll: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number,
+ children: PropTypes.node,
+ onScroll: PropTypes.func
+};
+
+Scroller.defaultProps = {
+ scrollDirection: scrollDirections.VERTICAL,
+ autoScroll: true
+};
+
+export default Scroller;
diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js
new file mode 100644
index 000000000..8b7b91fe2
--- /dev/null
+++ b/frontend/src/Components/SignalRConnector.js
@@ -0,0 +1,374 @@
+import $ from 'jquery';
+import 'signalr';
+import PropTypes from 'prop-types';
+import { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { repopulatePage } from 'Utilities/pagePopulator';
+import titleCase from 'Utilities/String/titleCase';
+import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions';
+import { setAppValue, setVersion } from 'Store/Actions/appActions';
+import { update, updateItem, removeItem } from 'Store/Actions/baseActions';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
+import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions';
+
+function getState(status) {
+ switch (status) {
+ case 0:
+ return 'connecting';
+ case 1:
+ return 'connected';
+ case 2:
+ return 'reconnecting';
+ case 4:
+ return 'disconnected';
+ default:
+ throw new Error(`invalid status ${status}`);
+ }
+}
+
+function isAppDisconnected(disconnectedTime) {
+ if (!disconnectedTime) {
+ return false;
+ }
+
+ return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180;
+}
+
+function getHandlerName(name) {
+ name = titleCase(name);
+ name = name.replace('/', '');
+
+ return `handle${name}`;
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.isReconnecting,
+ (state) => state.app.isDisconnected,
+ (state) => state.queue.paged.isPopulated,
+ (isReconnecting, isDisconnected, isQueuePopulated) => {
+ return {
+ isReconnecting,
+ isDisconnected,
+ isQueuePopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchCommands: fetchCommands,
+ dispatchUpdateCommand: updateCommand,
+ dispatchFinishCommand: finishCommand,
+ dispatchSetAppValue: setAppValue,
+ dispatchSetVersion: setVersion,
+ dispatchUpdate: update,
+ dispatchUpdateItem: updateItem,
+ dispatchRemoveItem: removeItem,
+ dispatchFetchHealth: fetchHealth,
+ dispatchFetchQueue: fetchQueue,
+ dispatchFetchQueueDetails: fetchQueueDetails,
+ dispatchFetchTags: fetchTags,
+ dispatchFetchTagDetails: fetchTagDetails
+};
+
+class SignalRConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] };
+ this.signalRconnection = null;
+ this.retryInterval = 1;
+ this.retryTimeoutId = null;
+ this.disconnectedTime = null;
+ }
+
+ componentDidMount() {
+ console.log('Starting signalR');
+
+ this.signalRconnection = $.connection('/signalr', { apiKey: window.Sonarr.apiKey });
+
+ this.signalRconnection.stateChanged(this.onStateChanged);
+ this.signalRconnection.received(this.onReceived);
+ this.signalRconnection.reconnecting(this.onReconnecting);
+ this.signalRconnection.disconnected(this.onDisconnected);
+
+ this.signalRconnection.start(this.signalRconnectionOptions);
+ }
+
+ componentWillUnmount() {
+ if (this.retryTimeoutId) {
+ this.retryTimeoutId = clearTimeout(this.retryTimeoutId);
+ }
+
+ this.signalRconnection.stop();
+ this.signalRconnection = null;
+ }
+
+ //
+ // Control
+
+ retryConnection = () => {
+ if (isAppDisconnected(this.disconnectedTime)) {
+ this.setState({
+ isDisconnected: true
+ });
+ }
+
+ this.retryTimeoutId = setTimeout(() => {
+ if (!this.signalRconnection) {
+ console.error('signalR: Connection was disposed');
+ return;
+ }
+
+ this.signalRconnection.start(this.signalRconnectionOptions);
+ this.retryInterval = Math.min(this.retryInterval + 1, 10);
+ }, this.retryInterval * 1000);
+ }
+
+ handleMessage = (message) => {
+ const {
+ name,
+ body
+ } = message;
+
+ const handler = this[getHandlerName(name)];
+
+ if (handler) {
+ handler(body);
+ return;
+ }
+
+ console.error(`signalR: Unable to find handler for ${name}`);
+ }
+
+ handleCalendar = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'calendar',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleCommand = (body) => {
+ if (body.action === 'sync') {
+ this.props.dispatchFetchCommands();
+ return;
+ }
+
+ const resource = body.resource;
+ const status = resource.status;
+
+ // Both sucessful and failed commands need to be
+ // completed, otherwise they spin until they timeout.
+
+ if (status === 'completed' || status === 'failed') {
+ this.props.dispatchFinishCommand(resource);
+ } else {
+ this.props.dispatchUpdateCommand(resource);
+ }
+ }
+
+ handleEpisode = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'episodes',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleEpisodefile = (body) => {
+ const section = 'episodeFiles';
+
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...body.resource });
+
+ // Repopulate the page to handle recently imported file
+ repopulatePage('episodeFileUpdated');
+ } else if (body.action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: body.resource.id });
+ }
+ }
+
+ handleHealth = () => {
+ this.props.dispatchFetchHealth();
+ }
+
+ handleSeries = (body) => {
+ const action = body.action;
+ const section = 'series';
+
+ if (action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...body.resource });
+ } else if (action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: body.resource.id });
+ }
+ }
+
+ handleQueue = () => {
+ if (this.props.isQueuePopulated) {
+ this.props.dispatchFetchQueue();
+ }
+ }
+
+ handleQueueDetails = () => {
+ this.props.dispatchFetchQueueDetails();
+ }
+
+ handleQueueStatus = (body) => {
+ this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
+ }
+
+ handleVersion = (body) => {
+ const version = body.Version;
+
+ this.props.dispatchSetVersion({ version });
+ }
+
+ handleWantedCutoff = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'cutoffUnmet',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleWantedMissing = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'missing',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleSystemTask = () => {
+ // No-op for now, we may want this later
+ }
+
+ handleTag = (body) => {
+ if (body.action === 'sync') {
+ this.props.dispatchFetchTags();
+ this.props.dispatchFetchTagDetails();
+ return;
+ }
+ }
+
+ //
+ // Listeners
+
+ onStateChanged = (change) => {
+ const state = getState(change.newState);
+ console.log(`signalR: ${state}`);
+
+ if (state === 'connected') {
+ // Clear disconnected time
+ this.disconnectedTime = null;
+
+ const {
+ dispatchFetchCommands,
+ dispatchSetAppValue
+ } = this.props;
+
+ // Repopulate the page (if a repopulator is set) to ensure things
+ // are in sync after reconnecting.
+
+ if (this.props.isReconnecting || this.props.isDisconnected) {
+ dispatchFetchCommands();
+ repopulatePage();
+ }
+
+ dispatchSetAppValue({
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false,
+ isRestarting: false
+ });
+
+ this.retryInterval = 5;
+
+ if (this.retryTimeoutId) {
+ clearTimeout(this.retryTimeoutId);
+ }
+ }
+ }
+
+ onReceived = (message) => {
+ console.debug('signalR: received', message.name, message.body);
+
+ this.handleMessage(message);
+ }
+
+ onReconnecting = () => {
+ if (window.Sonarr.unloading) {
+ return;
+ }
+
+ if (!this.disconnectedTime) {
+ this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
+ }
+
+ this.props.dispatchSetAppValue({
+ isReconnecting: true
+ });
+ }
+
+ onDisconnected = () => {
+ if (window.Sonarr.unloading) {
+ return;
+ }
+
+ if (!this.disconnectedTime) {
+ this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
+ }
+
+ this.props.dispatchSetAppValue({
+ isConnected: false,
+ isReconnecting: true,
+ isDisconnected: isAppDisconnected(this.disconnectedTime)
+ });
+
+ this.retryConnection();
+ }
+
+ //
+ // Render
+
+ render() {
+ return null;
+ }
+}
+
+SignalRConnector.propTypes = {
+ isReconnecting: PropTypes.bool.isRequired,
+ isDisconnected: PropTypes.bool.isRequired,
+ isQueuePopulated: PropTypes.bool.isRequired,
+ dispatchFetchCommands: PropTypes.func.isRequired,
+ dispatchUpdateCommand: PropTypes.func.isRequired,
+ dispatchFinishCommand: PropTypes.func.isRequired,
+ dispatchSetAppValue: PropTypes.func.isRequired,
+ dispatchSetVersion: PropTypes.func.isRequired,
+ dispatchUpdate: PropTypes.func.isRequired,
+ dispatchUpdateItem: PropTypes.func.isRequired,
+ dispatchRemoveItem: PropTypes.func.isRequired,
+ dispatchFetchHealth: PropTypes.func.isRequired,
+ dispatchFetchQueue: PropTypes.func.isRequired,
+ dispatchFetchQueueDetails: PropTypes.func.isRequired,
+ dispatchFetchTags: PropTypes.func.isRequired,
+ dispatchFetchTagDetails: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector);
diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js
new file mode 100644
index 000000000..d21674d9e
--- /dev/null
+++ b/frontend/src/Components/SpinnerIcon.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from './Icon';
+
+function SpinnerIcon(props) {
+ const {
+ name,
+ spinningName,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+}
+
+SpinnerIcon.propTypes = {
+ name: PropTypes.object.isRequired,
+ spinningName: PropTypes.object.isRequired,
+ isSpinning: PropTypes.bool.isRequired
+};
+
+SpinnerIcon.defaultProps = {
+ spinningName: icons.SPINNER
+};
+
+export default SpinnerIcon;
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css
new file mode 100644
index 000000000..7be20ce5d
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.css
@@ -0,0 +1,5 @@
+.cell {
+ composes: cell from './TableRowCell.css';
+
+ width: 180px;
+}
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js
new file mode 100644
index 000000000..93004b447
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import TableRowCell from './TableRowCell';
+import styles from './RelativeDateCell.css';
+
+function RelativeDateCell(props) {
+ const {
+ className,
+ date,
+ includeSeconds,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ component: Component,
+ dispatch,
+ ...otherProps
+ } = props;
+
+ if (!date) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })}
+
+ );
+}
+
+RelativeDateCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ date: PropTypes.string,
+ includeSeconds: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ component: PropTypes.func,
+ dispatch: PropTypes.func
+};
+
+RelativeDateCell.defaultProps = {
+ className: styles.cell,
+ includeSeconds: false,
+ component: TableRowCell
+};
+
+export default RelativeDateCell;
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js
new file mode 100644
index 000000000..ed996abbe
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import RelativeDateCell from './RelativeDateCell';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return _.pick(uiSettings, [
+ 'showRelativeDates',
+ 'shortDateFormat',
+ 'longDateFormat',
+ 'timeFormat'
+ ]);
+ }
+ );
+}
+
+export default connect(createMapStateToProps, null)(RelativeDateCell);
diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css
new file mode 100644
index 000000000..1c3e6fc5a
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCell.css
@@ -0,0 +1,11 @@
+.cell {
+ padding: 8px;
+ border-top: 1px solid #eee;
+ line-height: 1.52857143;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .cell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/Cells/TableRowCell.js b/frontend/src/Components/Table/Cells/TableRowCell.js
new file mode 100644
index 000000000..f66bbf3aa
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCell.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './TableRowCell.css';
+
+class TableRowCell extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+TableRowCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
+};
+
+TableRowCell.defaultProps = {
+ className: styles.cell
+};
+
+export default TableRowCell;
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.css b/frontend/src/Components/Table/Cells/TableRowCellButton.css
new file mode 100644
index 000000000..f01e7cba6
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css
@@ -0,0 +1,4 @@
+.cell {
+ composes: cell from './TableRowCell.css';
+ composes: link from 'Components/Link/Link.css';
+}
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js
new file mode 100644
index 000000000..ff50d3bc9
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+import TableRowCell from './TableRowCell';
+import styles from './TableRowCellButton.css';
+
+function TableRowCellButton({ className, ...otherProps }) {
+ return (
+
+ );
+}
+
+TableRowCellButton.propTypes = {
+ className: PropTypes.string.isRequired
+};
+
+TableRowCellButton.defaultProps = {
+ className: styles.cell
+};
+
+export default TableRowCellButton;
diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css b/frontend/src/Components/Table/Cells/TableSelectCell.css
new file mode 100644
index 000000000..21ab944d7
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.css
@@ -0,0 +1,11 @@
+.selectCell {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js
new file mode 100644
index 000000000..9c10f4444
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import TableRowCell from './TableRowCell';
+import styles from './TableSelectCell.css';
+
+class TableSelectCell extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ id,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value: isSelected });
+ }
+
+ componentWillUnmount() {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value: null });
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ value, shiftKey }, a, b, c, d) => {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value, shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ id,
+ isSelected,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+TableSelectCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+TableSelectCell.defaultProps = {
+ className: styles.selectCell,
+ isSelected: false
+};
+
+export default TableSelectCell;
diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css
new file mode 100644
index 000000000..e4cffe1c4
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css
@@ -0,0 +1,14 @@
+.cell {
+ @add-mixin truncate;
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ flex-grow: 0;
+ flex-shrink: 1;
+ white-space: nowrap;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .cell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.js b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js
new file mode 100644
index 000000000..42999216f
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableRowCell.css';
+
+function VirtualTableRowCell(props) {
+ const {
+ className,
+ children
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableRowCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
+};
+
+VirtualTableRowCell.defaultProps = {
+ className: styles.cell
+};
+
+export default VirtualTableRowCell;
diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css
new file mode 100644
index 000000000..e1016aa8a
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css
@@ -0,0 +1,11 @@
+.cell {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 36px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js
new file mode 100644
index 000000000..a773aab58
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableRowCell from './VirtualTableRowCell';
+import styles from './VirtualTableSelectCell.css';
+
+export function virtualTableSelectCellRenderer(cellProps) {
+ const {
+ cellKey,
+ rowData,
+ columnData,
+ ...otherProps
+ } = cellProps;
+
+ return (
+
+ );
+}
+
+class VirtualTableSelectCell extends Component {
+
+ //
+ // Listeners
+
+ onChange = ({ value, shiftKey }) => {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value, shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ inputClassName,
+ id,
+ isSelected,
+ isDisabled,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+VirtualTableSelectCell.propTypes = {
+ inputClassName: PropTypes.string.isRequired,
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+VirtualTableSelectCell.defaultProps = {
+ inputClassName: styles.input,
+ isSelected: false
+};
+
+export default VirtualTableSelectCell;
diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css
new file mode 100644
index 000000000..46d49826a
--- /dev/null
+++ b/frontend/src/Components/Table/Table.css
@@ -0,0 +1,16 @@
+.tableContainer {
+ overflow-x: auto;
+}
+
+.table {
+ max-width: 100%;
+ width: 100%;
+ border-collapse: collapse;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .tableContainer {
+ overflow-y: hidden;
+ width: 100%;
+ }
+}
diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js
new file mode 100644
index 000000000..5cfbc4c8f
--- /dev/null
+++ b/frontend/src/Components/Table/Table.js
@@ -0,0 +1,160 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, scrollDirections } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import Scroller from 'Components/Scroller/Scroller';
+import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
+import TableHeader from './TableHeader';
+import TableHeaderCell from './TableHeaderCell';
+import TableSelectAllHeaderCell from './TableSelectAllHeaderCell';
+import styles from './Table.css';
+
+const tableHeaderCellProps = [
+ 'sortKey',
+ 'sortDirection'
+];
+
+function getTableHeaderCellProps(props) {
+ return _.reduce(tableHeaderCellProps, (result, key) => {
+ if (props.hasOwnProperty(key)) {
+ result[key] = props[key];
+ }
+
+ return result;
+ }, {});
+}
+
+class Table extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isTableOptionsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onTableOptionsPress = () => {
+ this.setState({ isTableOptionsModalOpen: true });
+ }
+
+ onTableOptionsModalClose = () => {
+ this.setState({ isTableOptionsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ selectAll,
+ columns,
+ optionsComponent,
+ pageSize,
+ canModifyColumns,
+ children,
+ onSortPress,
+ onTableOptionChange,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ selectAll &&
+
+ }
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if ((name === 'actions' || name === 'details') && onTableOptionChange) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {column.label}
+
+ );
+ })
+ }
+
+ {
+ !!onTableOptionChange &&
+
+ }
+
+
+ {children}
+
+
+ );
+ }
+}
+
+Table.propTypes = {
+ className: PropTypes.string,
+ selectAll: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ optionsComponent: PropTypes.func,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func,
+ onTableOptionChange: PropTypes.func
+};
+
+Table.defaultProps = {
+ className: styles.table,
+ selectAll: false
+};
+
+export default Table;
diff --git a/frontend/src/Components/Table/TableBody.js b/frontend/src/Components/Table/TableBody.js
new file mode 100644
index 000000000..5cc60d6f4
--- /dev/null
+++ b/frontend/src/Components/Table/TableBody.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+
+class TableBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+ {children}
+ );
+ }
+
+}
+
+TableBody.propTypes = {
+ children: PropTypes.node
+};
+
+export default TableBody;
diff --git a/frontend/src/Components/Table/TableHeader.js b/frontend/src/Components/Table/TableHeader.js
new file mode 100644
index 000000000..81943e919
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeader.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+
+class TableHeader extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+TableHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default TableHeader;
diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css
new file mode 100644
index 000000000..c2c4f58c8
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeaderCell.css
@@ -0,0 +1,16 @@
+.headerCell {
+ padding: 8px;
+ border: none !important;
+ text-align: left;
+ font-weight: bold;
+}
+
+.sortIcon {
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .headerCell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js
new file mode 100644
index 000000000..73c4b7ec2
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeaderCell.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './TableHeaderCell.css';
+
+class TableHeaderCell extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ fixedSortDirection
+ } = this.props;
+
+ if (fixedSortDirection) {
+ this.props.onSortPress(name, fixedSortDirection);
+ } else {
+ this.props.onSortPress(name);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ name,
+ isSortable,
+ isVisible,
+ isModifiable,
+ sortKey,
+ sortDirection,
+ fixedSortDirection,
+ children,
+ onSortPress,
+ ...otherProps
+ } = this.props;
+
+ const isSorting = isSortable && sortKey === name;
+ const sortIcon = sortDirection === sortDirections.ASCENDING ?
+ icons.SORT_ASCENDING :
+ icons.SORT_DESCENDING;
+
+ return (
+ isSortable ?
+
+ {children}
+
+ {
+ isSorting &&
+
+ }
+ :
+
+
+ {children}
+
+ );
+ }
+}
+
+TableHeaderCell.propTypes = {
+ className: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ isSortable: PropTypes.bool,
+ isVisible: PropTypes.bool,
+ isModifiable: PropTypes.bool,
+ sortKey: PropTypes.string,
+ fixedSortDirection: PropTypes.string,
+ sortDirection: PropTypes.string,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func
+};
+
+TableHeaderCell.defaultProps = {
+ className: styles.headerCell,
+ isSortable: false
+};
+
+export default TableHeaderCell;
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css
new file mode 100644
index 000000000..204773c3d
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css
@@ -0,0 +1,48 @@
+.column {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+}
+
+.checkContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 7px;
+ margin-left: 8px;
+}
+
+.label {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: 36px;
+ cursor: pointer;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.notDragable {
+ padding: 4px 0;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
new file mode 100644
index 000000000..a986be615
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TableOptionsColumn.css';
+
+function TableOptionsColumn(props) {
+ const {
+ name,
+ label,
+ isVisible,
+ isModifiable,
+ isDragging,
+ connectDragSource,
+ onVisibleChange
+ } = props;
+
+ return (
+
+
+
+
+ {label}
+
+
+ {
+ !!connectDragSource &&
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+ );
+}
+
+TableOptionsColumn.propTypes = {
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ isModifiable: PropTypes.bool.isRequired,
+ index: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ onVisibleChange: PropTypes.func.isRequired
+};
+
+export default TableOptionsColumn;
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js
new file mode 100644
index 000000000..b1d016529
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { TABLE_COLUMN } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import TableOptionsColumn from './TableOptionsColumn';
+import styles from './TableOptionsColumnDragPreview.css';
+
+const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
+const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class TableOptionsColumnDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== TABLE_COLUMN) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ return (
+
+
+
+ );
+ }
+}
+
+TableOptionsColumnDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview);
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css
new file mode 100644
index 000000000..9354a35c0
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css
@@ -0,0 +1,18 @@
+.columnDragSource {
+ padding: 4px 0;
+}
+
+.columnPlaceholder {
+ width: 100%;
+ height: 36px;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.columnPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.columnPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
new file mode 100644
index 000000000..80f03e430
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { TABLE_COLUMN } from 'Helpers/dragTypes';
+import TableOptionsColumn from './TableOptionsColumn';
+import styles from './TableOptionsColumnDragSource.css';
+
+const columnDragSource = {
+ beginDrag(column) {
+ return column;
+ },
+
+ endDrag(props, monitor, component) {
+ props.onColumnDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const columnDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().index;
+ const hoverIndex = props.index;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ // When moving up, only trigger if drag position is above 50% and
+ // when moving down, only trigger if drag position is below 50%.
+ // If we're moving down the hoverIndex needs to be increased
+ // by one so it's ordered properly. Otherwise the hoverIndex will work.
+
+ // Dragging downwards
+ if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
+ return;
+ }
+
+ // Dragging upwards
+ if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
+ return;
+ }
+
+ props.onColumnDragMove(dragIndex, hoverIndex);
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class TableOptionsColumnDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ label,
+ isVisible,
+ isModifiable,
+ index,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ onVisibleChange
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+TableOptionsColumnDragSource.propTypes = {
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ isModifiable: PropTypes.bool.isRequired,
+ index: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onVisibleChange: PropTypes.func.isRequired,
+ onColumnDragMove: PropTypes.func.isRequired,
+ onColumnDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ TABLE_COLUMN,
+ columnDropTarget,
+ collectDropTarget
+)(DragSource(
+ TABLE_COLUMN,
+ columnDragSource,
+ collectDragSource
+)(TableOptionsColumnDragSource));
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.css b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css
new file mode 100644
index 000000000..35544f32b
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css
@@ -0,0 +1,5 @@
+.columns {
+ margin-top: 10px;
+ width: 100%;
+ user-select: none;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
new file mode 100644
index 000000000..351d827ca
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
@@ -0,0 +1,252 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Modal from 'Components/Modal/Modal';
+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 TableOptionsColumn from './TableOptionsColumn';
+import TableOptionsColumnDragSource from './TableOptionsColumnDragSource';
+import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview';
+import styles from './TableOptionsModal.css';
+
+class TableOptionsModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPageSize: !!props.pageSize,
+ pageSize: props.pageSize,
+ pageSizeError: null,
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.pageSize !== this.state.pageSize) {
+ this.setState({ pageSize: this.props.pageSize });
+ }
+ }
+
+ //
+ // Listeners
+
+ onPageSizeChange = ({ value }) => {
+ let pageSizeError = null;
+
+ if (value < 5) {
+ pageSizeError = 'Page size must be at least 5';
+ } else if (value > 250) {
+ pageSizeError = 'Page size must not exceed 250';
+ } else {
+ this.props.onTableOptionChange({ pageSize: value });
+ }
+
+ this.setState({
+ pageSize: value,
+ pageSizeError
+ });
+ }
+
+ onVisibleChange = ({ name, value }) => {
+ const columns = _.cloneDeep(this.props.columns);
+
+ const column = _.find(columns, { name });
+ column.isVisible = value;
+
+ this.props.onTableOptionChange({ columns });
+ }
+
+ onColumnDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onColumnDragEnd = ({ id }, didDrop) => {
+ const {
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ const columns = _.cloneDeep(this.props.columns);
+ const items = columns.splice(dragIndex, 1);
+ columns.splice(dropIndex, 0, items[0]);
+
+ this.props.onTableOptionChange({ columns });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ columns,
+ canModifyColumns,
+ optionsComponent: OptionsComponent,
+ onTableOptionChange,
+ onModalClose
+ } = this.props;
+
+ const {
+ hasPageSize,
+ pageSize,
+ pageSizeError,
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex < dragIndex;
+ const isDraggingDown = isDragging && dropIndex > dragIndex;
+
+ return (
+
+
+
+ Table Options
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+TableOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool.isRequired,
+ optionsComponent: PropTypes.func,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+TableOptionsModal.defaultProps = {
+ canModifyColumns: true
+};
+
+export default DragDropContext(HTML5Backend)(TableOptionsModal);
diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css
new file mode 100644
index 000000000..17d300fb9
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.css
@@ -0,0 +1,77 @@
+.pager {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.loadingContainer,
+.controlsContainer,
+.recordsContainer {
+ flex: 0 1 33%;
+}
+
+.controlsContainer {
+ display: flex;
+ justify-content: center;
+}
+
+.recordsContainer {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin: 0;
+ margin-left: 5px;
+ text-align: left;
+}
+
+.controls {
+ display: flex;
+ align-items: center;
+ text-align: center;
+}
+
+.pageNumber {
+ line-height: 30px;
+}
+
+.pageLink {
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+}
+
+.records {
+ color: $disabledColor;
+}
+
+.disabledPageButton {
+ color: $disabledColor;
+}
+
+.pageSelect {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ padding: 0 2px;
+ height: 25px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .pager {
+ flex-wrap: wrap;
+ }
+
+ .loadingContainer,
+ .recordsContainer {
+ flex: 0 1 50%;
+ }
+
+ .controlsContainer {
+ flex: 0 1 100%;
+ order: -1;
+ }
+}
diff --git a/frontend/src/Components/Table/TablePager.js b/frontend/src/Components/Table/TablePager.js
new file mode 100644
index 000000000..3c7c5a8f1
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.js
@@ -0,0 +1,180 @@
+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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectInput from 'Components/Form/SelectInput';
+import styles from './TablePager.css';
+
+class TablePager extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isShowingPageSelect: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onOpenPageSelectClick = () => {
+ this.setState({ isShowingPageSelect: true });
+ }
+
+ onPageSelect = ({ value: page }) => {
+ this.setState({ isShowingPageSelect: false });
+ this.props.onPageSelect(parseInt(page));
+ }
+
+ onPageSelectBlur = () => {
+ this.setState({ isShowingPageSelect: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ page,
+ totalPages,
+ totalRecords,
+ isFetching,
+ onFirstPagePress,
+ onPreviousPagePress,
+ onNextPagePress,
+ onLastPagePress
+ } = this.props;
+
+ const isShowingPageSelect = this.state.isShowingPageSelect;
+ const pages = Array.from(new Array(totalPages), (x, i) => {
+ const pageNumber = i + 1;
+
+ return {
+ key: pageNumber,
+ value: pageNumber
+ };
+ });
+
+ if (!page) {
+ return null;
+ }
+
+ const isFirstPage = page === 1;
+ const isLastPage = page === totalPages;
+
+ return (
+
+
+ {
+ isFetching &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !isShowingPageSelect &&
+
+ {page} / {totalPages}
+
+ }
+
+ {
+ isShowingPageSelect &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total records: {totalRecords}
+
+
+
+ );
+ }
+
+}
+
+TablePager.propTypes = {
+ page: PropTypes.number,
+ totalPages: PropTypes.number,
+ totalRecords: PropTypes.number,
+ isFetching: PropTypes.bool,
+ onFirstPagePress: PropTypes.func.isRequired,
+ onPreviousPagePress: PropTypes.func.isRequired,
+ onNextPagePress: PropTypes.func.isRequired,
+ onLastPagePress: PropTypes.func.isRequired,
+ onPageSelect: PropTypes.func.isRequired
+};
+
+export default TablePager;
diff --git a/frontend/src/Components/Table/TableRow.css b/frontend/src/Components/Table/TableRow.css
new file mode 100644
index 000000000..dcc6ad8cf
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.css
@@ -0,0 +1,7 @@
+.row {
+ transition: background-color 500ms;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/Components/Table/TableRow.js b/frontend/src/Components/Table/TableRow.js
new file mode 100644
index 000000000..c76083183
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './TableRow.css';
+
+function TableRow(props) {
+ const {
+ className,
+ children,
+ overlayContent,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+TableRow.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ overlayContent: PropTypes.bool
+};
+
+TableRow.defaultProps = {
+ className: styles.row
+};
+
+export default TableRow;
diff --git a/frontend/src/Components/Table/TableRowButton.css b/frontend/src/Components/Table/TableRowButton.css
new file mode 100644
index 000000000..70a2238ca
--- /dev/null
+++ b/frontend/src/Components/Table/TableRowButton.css
@@ -0,0 +1,4 @@
+.row {
+ composes: link from 'Components/Link/Link.css';
+ composes: row from './TableRow.css';
+}
diff --git a/frontend/src/Components/Table/TableRowButton.js b/frontend/src/Components/Table/TableRowButton.js
new file mode 100644
index 000000000..7ff679673
--- /dev/null
+++ b/frontend/src/Components/Table/TableRowButton.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import TableRow from './TableRow';
+import styles from './TableRowButton.css';
+
+function TableRowButton(props) {
+ return (
+
+ );
+}
+
+export default TableRowButton;
diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.css b/frontend/src/Components/Table/TableSelectAllHeaderCell.css
new file mode 100644
index 000000000..6090e6e9c
--- /dev/null
+++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.css
@@ -0,0 +1,11 @@
+.selectAllHeaderCell {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ width: 30px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.js b/frontend/src/Components/Table/TableSelectAllHeaderCell.js
new file mode 100644
index 000000000..c889c32ae
--- /dev/null
+++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableHeaderCell from './TableHeaderCell';
+import styles from './TableSelectAllHeaderCell.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+function TableSelectAllHeaderCell(props) {
+ const {
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ const value = getValue(allSelected, allUnselected);
+
+ return (
+
+
+
+ );
+}
+
+TableSelectAllHeaderCell.propTypes = {
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default TableSelectAllHeaderCell;
diff --git a/frontend/src/Components/Table/VirtualTable.css b/frontend/src/Components/Table/VirtualTable.css
new file mode 100644
index 000000000..3287c5643
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.css
@@ -0,0 +1,3 @@
+.tableContainer {
+ width: 100%;
+}
diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js
new file mode 100644
index 000000000..a13807647
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.js
@@ -0,0 +1,167 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { WindowScroller } from 'react-virtualized';
+import { scrollDirections } from 'Helpers/Props';
+import Measure from 'Components/Measure';
+import Scroller from 'Components/Scroller/Scroller';
+import VirtualTableBody from './VirtualTableBody';
+import styles from './VirtualTable.css';
+
+const ROW_HEIGHT = 38;
+
+function overscanIndicesGetter(options) {
+ const {
+ cellCount,
+ overscanCellsCount,
+ startIndex,
+ stopIndex
+ } = options;
+
+ // The default getter takes the scroll direction into account,
+ // but that can cause issues. Ignore the scroll direction and
+ // always over return more items.
+
+ const overscanStartIndex = startIndex - overscanCellsCount;
+ const overscanStopIndex = stopIndex + overscanCellsCount;
+
+ return {
+ overscanStartIndex: Math.max(0, overscanStartIndex),
+ overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex)
+ };
+}
+
+class VirtualTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0
+ };
+
+ this._isInitialized = false;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps, preState) {
+ const scrollIndex = this.props.scrollIndex;
+
+ if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
+ const scrollTop = (scrollIndex + 1) * ROW_HEIGHT + 20;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+
+ //
+ // Control
+
+ rowGetter = ({ index }) => {
+ return this.props.items[index];
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({
+ width
+ });
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ items,
+ isSmallScreen,
+ header,
+ headerHeight,
+ scrollTop,
+ rowRenderer,
+ onScroll,
+ ...otherProps
+ } = this.props;
+
+ const {
+ width
+ } = this.state;
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ {header}
+
+
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+VirtualTable.propTypes = {
+ className: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ scrollIndex: PropTypes.number,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ header: PropTypes.node.isRequired,
+ headerHeight: PropTypes.number.isRequired,
+ rowRenderer: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+VirtualTable.defaultProps = {
+ className: styles.tableContainer,
+ headerHeight: 38,
+ onRender: () => {}
+};
+
+export default VirtualTable;
diff --git a/frontend/src/Components/Table/VirtualTableBody.css b/frontend/src/Components/Table/VirtualTableBody.css
new file mode 100644
index 000000000..12768646d
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableBody.css
@@ -0,0 +1,3 @@
+.tableBodyContainer {
+ position: relative;
+}
diff --git a/frontend/src/Components/Table/VirtualTableBody.js b/frontend/src/Components/Table/VirtualTableBody.js
new file mode 100644
index 000000000..de88bd03c
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableBody.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Grid } from 'react-virtualized';
+import styles from './VirtualTableBody.css';
+
+class VirtualTableBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+VirtualTableBody.propTypes = {
+ className: PropTypes.string.isRequired
+};
+
+VirtualTableBody.defaultProps = {
+ className: styles.tableBodyContainer
+};
+
+export default VirtualTableBody;
diff --git a/frontend/src/Components/Table/VirtualTableHeader.css b/frontend/src/Components/Table/VirtualTableHeader.css
new file mode 100644
index 000000000..4b757c1f8
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeader.css
@@ -0,0 +1,3 @@
+.header {
+ display: flex;
+}
diff --git a/frontend/src/Components/Table/VirtualTableHeader.js b/frontend/src/Components/Table/VirtualTableHeader.js
new file mode 100644
index 000000000..cf6a0f47b
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeader.js
@@ -0,0 +1,17 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableHeader.css';
+
+function VirtualTableHeader({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default VirtualTableHeader;
diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.css b/frontend/src/Components/Table/VirtualTableHeaderCell.css
new file mode 100644
index 000000000..c2c4f58c8
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeaderCell.css
@@ -0,0 +1,16 @@
+.headerCell {
+ padding: 8px;
+ border: none !important;
+ text-align: left;
+ font-weight: bold;
+}
+
+.sortIcon {
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .headerCell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.js b/frontend/src/Components/Table/VirtualTableHeaderCell.js
new file mode 100644
index 000000000..bf51062e9
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeaderCell.js
@@ -0,0 +1,107 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './VirtualTableHeaderCell.css';
+
+export function headerRenderer(headerProps) {
+ const {
+ columnData = {},
+ dataKey,
+ label
+ } = headerProps;
+
+ return (
+
+ {label}
+
+ );
+}
+
+class VirtualTableHeaderCell extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ fixedSortDirection
+ } = this.props;
+
+ if (fixedSortDirection) {
+ this.props.onSortPress(name, fixedSortDirection);
+ } else {
+ this.props.onSortPress(name);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ name,
+ isSortable,
+ sortKey,
+ sortDirection,
+ fixedSortDirection,
+ children,
+ onSortPress,
+ ...otherProps
+ } = this.props;
+
+ const isSorting = isSortable && sortKey === name;
+ const sortIcon = sortDirection === sortDirections.ASCENDING ?
+ icons.SORT_ASCENDING :
+ icons.SORT_DESCENDING;
+
+ return (
+ isSortable ?
+
+ {children}
+
+ {
+ isSorting &&
+
+ }
+ :
+
+
+ {children}
+
+ );
+ }
+}
+
+VirtualTableHeaderCell.propTypes = {
+ className: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ isSortable: PropTypes.bool,
+ sortKey: PropTypes.string,
+ fixedSortDirection: PropTypes.string,
+ sortDirection: PropTypes.string,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func
+};
+
+VirtualTableHeaderCell.defaultProps = {
+ className: styles.headerCell,
+ isSortable: false
+};
+
+export default VirtualTableHeaderCell;
diff --git a/frontend/src/Components/Table/VirtualTableRow.css b/frontend/src/Components/Table/VirtualTableRow.css
new file mode 100644
index 000000000..f4c825b64
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRow.css
@@ -0,0 +1,14 @@
+.row {
+ display: flex;
+ transition: background-color 500ms;
+
+ &:hover {
+ background-color: #fafbfc;
+ }
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .row {
+ overflow-x: visible !important;
+ }
+}
diff --git a/frontend/src/Components/Table/VirtualTableRow.js b/frontend/src/Components/Table/VirtualTableRow.js
new file mode 100644
index 000000000..0a423902e
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRow.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableRow.css';
+
+function VirtualTableRow(props) {
+ const {
+ className,
+ children,
+ style,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableRow.propTypes = {
+ className: PropTypes.string.isRequired,
+ style: PropTypes.object.isRequired,
+ children: PropTypes.node
+};
+
+VirtualTableRow.defaultProps = {
+ className: styles.row
+};
+
+export default VirtualTableRow;
diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css
new file mode 100644
index 000000000..1f3f7fb30
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css
@@ -0,0 +1,11 @@
+.selectAllHeaderCell {
+ composes: headerCell from 'Components/Table/TableHeaderCell.css';
+
+ flex: 0 0 36px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js
new file mode 100644
index 000000000..58b246763
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableHeaderCell from './VirtualTableHeaderCell';
+import styles from './VirtualTableSelectAllHeaderCell.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+function VirtualTableSelectAllHeaderCell(props) {
+ const {
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ const value = getValue(allSelected, allUnselected);
+
+ return (
+
+
+
+ );
+}
+
+VirtualTableSelectAllHeaderCell.propTypes = {
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default VirtualTableSelectAllHeaderCell;
diff --git a/frontend/src/Components/TagList.css b/frontend/src/Components/TagList.css
new file mode 100644
index 000000000..c1e5567bd
--- /dev/null
+++ b/frontend/src/Components/TagList.css
@@ -0,0 +1,3 @@
+.tags {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js
new file mode 100644
index 000000000..485651bdc
--- /dev/null
+++ b/frontend/src/Components/TagList.js
@@ -0,0 +1,38 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from './Label';
+import styles from './TagList.css';
+
+function TagList({ tags, tagList }) {
+ return (
+
+ {
+ tags.map((t) => {
+ const tag = _.find(tagList, { id: t });
+
+ if (!tag) {
+ return null;
+ }
+
+ return (
+
+ {tag.label}
+
+ );
+ })
+ }
+
+ );
+}
+
+TagList.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default TagList;
diff --git a/frontend/src/Components/TagListConnector.js b/frontend/src/Components/TagListConnector.js
new file mode 100644
index 000000000..be7e618e3
--- /dev/null
+++ b/frontend/src/Components/TagListConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagList from './TagList';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagsSelector(),
+ (tagList) => {
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(TagList);
diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css
new file mode 100644
index 000000000..f7b87f0b9
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.css
@@ -0,0 +1,105 @@
+.tether {
+ z-index: 2000;
+}
+
+.popoverContainer {
+ margin: 10px 15px;
+}
+
+.popover {
+ position: relative;
+ background-color: $white;
+ box-shadow: 0 5px 10px $popoverShadowColor;
+}
+
+.arrow,
+.arrow::after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-width: 11px;
+ border-style: solid;
+ border-color: transparent;
+}
+
+.arrow::after {
+ border-width: 10px;
+ content: '';
+}
+
+.top {
+ bottom: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-color: $popoverArrowBorderColor;
+ border-bottom-width: 0;
+
+ &::after {
+ bottom: 1px;
+ margin-left: -10px;
+ border-top-color: $white;
+ border-bottom-width: 0;
+ content: ' ';
+ }
+}
+
+.right {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-right-color: $popoverArrowBorderColor;
+ border-left-width: 0;
+
+ &::after {
+ bottom: -10px;
+ left: 1px;
+ border-right-color: $white;
+ border-left-width: 0;
+ content: ' ';
+ }
+}
+
+.bottom {
+ top: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+ border-bottom-color: $popoverArrowBorderColor;
+
+ &::after {
+ top: 1px;
+ margin-left: -10px;
+ border-top-width: 0;
+ border-bottom-color: $white;
+ content: ' ';
+ }
+}
+
+.left {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+ border-left-color: $popoverArrowBorderColor;
+
+ &::after {
+ right: 1px;
+ bottom: -10px;
+ border-right-width: 0;
+ border-left-color: $white;
+ content: ' ';
+ }
+}
+
+.title {
+ padding: 10px 20px;
+ border-bottom: 1px solid $popoverTitleBorderColor;
+ background-color: $popoverTitleBackgroundColor;
+ font-size: 16px;
+}
+
+.body {
+ overflow: auto;
+ padding: 10px;
+}
diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js
new file mode 100644
index 000000000..c958fce1b
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.js
@@ -0,0 +1,160 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import { tooltipPositions } from 'Helpers/Props';
+import styles from './Popover.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [tooltipPositions.TOP]: {
+ ...baseTetherOptions,
+ attachment: 'bottom center',
+ targetAttachment: 'top center'
+ },
+
+ [tooltipPositions.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'middle left',
+ targetAttachment: 'middle right'
+ },
+
+ [tooltipPositions.BOTTOM]: {
+ ...baseTetherOptions,
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+ },
+
+ [tooltipPositions.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'middle right',
+ targetAttachment: 'middle left'
+ }
+};
+
+class Popover extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false
+ };
+
+ this._closeTimeout = null;
+ }
+
+ componentWillUnmount() {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ if (isMobileUtil()) {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+ }
+
+ onMouseEnter = () => {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this._closeTimeout = setTimeout(() => {
+ this.setState({ isOpen: false });
+ }, 100);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ anchor,
+ title,
+ body,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+
+
+
+
+ {title}
+
+
+
+ {body}
+
+
+
+ }
+
+ );
+ }
+}
+
+Popover.propTypes = {
+ className: PropTypes.string,
+ anchor: PropTypes.node.isRequired,
+ title: PropTypes.string.isRequired,
+ body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ position: PropTypes.oneOf(tooltipPositions.all)
+};
+
+Popover.defaultProps = {
+ position: tooltipPositions.TOP
+};
+
+export default Popover;
diff --git a/frontend/src/Components/Tooltip/Tooltip.css b/frontend/src/Components/Tooltip/Tooltip.css
new file mode 100644
index 000000000..d1d798e0f
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.css
@@ -0,0 +1,161 @@
+.tether {
+ z-index: 2000;
+}
+
+.tooltipContainer {
+ margin: 10px 15px;
+}
+
+.tooltip {
+ position: relative;
+
+ &.default {
+ background-color: $white;
+ box-shadow: 0 5px 10px $popoverShadowColor;
+ }
+
+ &.inverse {
+ background-color: $themeDarkColor;
+ box-shadow: 0 5px 10px $popoverShadowInverseColor;
+ }
+}
+
+.arrow,
+.arrow::after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-width: 11px;
+ border-style: solid;
+ border-color: transparent;
+}
+
+.arrow::after {
+ border-width: 10px;
+ content: '';
+}
+
+.top {
+ bottom: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-bottom-width: 0;
+
+ &::after {
+ bottom: 1px;
+ margin-left: -10px;
+ border-bottom-width: 0;
+ content: ' ';
+
+ &.default {
+ border-top-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-top-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-top-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-top-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.right {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-left-width: 0;
+
+ &::after {
+ bottom: -10px;
+ left: 1px;
+ border-left-width: 0;
+ content: ' ';
+
+ &.default {
+ border-right-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-right-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-right-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-right-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.bottom {
+ top: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+
+ &::after {
+ top: 1px;
+ margin-left: -10px;
+ border-top-width: 0;
+ content: ' ';
+
+ &.default {
+ border-bottom-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-bottom-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-bottom-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-bottom-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.left {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+
+ &::after {
+ right: 1px;
+ bottom: -10px;
+ border-right-width: 0;
+ content: ' ';
+
+ &.default {
+ border-left-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-left-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-left-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-left-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.body {
+ padding: 5px;
+}
diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js
new file mode 100644
index 000000000..43caf87e8
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.js
@@ -0,0 +1,163 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import { kinds, tooltipPositions } from 'Helpers/Props';
+import styles from './Tooltip.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [tooltipPositions.TOP]: {
+ ...baseTetherOptions,
+ attachment: 'bottom center',
+ targetAttachment: 'top center'
+ },
+
+ [tooltipPositions.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'middle left',
+ targetAttachment: 'middle right'
+ },
+
+ [tooltipPositions.BOTTOM]: {
+ ...baseTetherOptions,
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+ },
+
+ [tooltipPositions.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'middle right',
+ targetAttachment: 'middle left'
+ }
+};
+
+class Tooltip extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false
+ };
+
+ this._closeTimeout = null;
+ }
+
+ componentWillUnmount() {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ if (isMobileUtil()) {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+ }
+
+ onMouseEnter = () => {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this._closeTimeout = setTimeout(() => {
+ this.setState({ isOpen: false });
+ }, 100);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ anchor,
+ tooltip,
+ kind,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+ }
+
+ );
+ }
+}
+
+Tooltip.propTypes = {
+ className: PropTypes.string,
+ anchor: PropTypes.node.isRequired,
+ tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
+ position: PropTypes.oneOf(tooltipPositions.all)
+};
+
+Tooltip.defaultProps = {
+ kind: kinds.DEFAULT,
+ position: tooltipPositions.TOP
+};
+
+export default Tooltip;
diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js
new file mode 100644
index 000000000..f43139e4a
--- /dev/null
+++ b/frontend/src/Components/keyboardShortcuts.js
@@ -0,0 +1,102 @@
+import React, { Component } from 'react';
+import Mousetrap from 'mousetrap';
+import getDisplayName from 'Helpers/getDisplayName';
+
+export const shortcuts = {
+ OPEN_KEYBOARD_SHORTCUTS_MODAL: {
+ key: '?',
+ name: 'Open This Modal'
+ },
+
+ SERIES_SEARCH_INPUT: {
+ key: 's',
+ name: 'Focus Search Box'
+ },
+
+ SAVE_SETTINGS: {
+ key: 'mod+s',
+ name: 'Save Settings'
+ }
+};
+
+function keyboardShortcuts(WrappedComponent) {
+ class KeyboardShortcuts extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+ this._mousetrapBindings = {};
+ this._mousetrap = new Mousetrap();
+ this._mousetrap.stopCallback = this.stopCallback;
+ }
+
+ componentWillUnmount() {
+ this.unbindAllShortcuts();
+ this._mousetrap = null;
+ }
+
+ //
+ // Control
+
+ bindShortcut = (key, callback, options = {}) => {
+ this._mousetrap.bind(key, callback);
+ this._mousetrapBindings[key] = options;
+ }
+
+ unbindShortcut = (key) => {
+ delete this._mousetrapBindings[key];
+ this._mousetrap.unbind(key);
+ }
+
+ unbindAllShortcuts = () => {
+ const keys = Object.keys(this._mousetrapBindings);
+
+ if (!keys.length) {
+ return;
+ }
+
+ keys.forEach((binding) => {
+ this._mousetrap.unbind(binding);
+ });
+
+ this._mousetrapBindings = {};
+ }
+
+ stopCallback = (event, element, combo) => {
+ const binding = this._mousetrapBindings[combo];
+
+ if (!binding || binding.isGlobal) {
+ return false;
+ }
+
+ return (
+ element.tagName === 'INPUT' ||
+ element.tagName === 'SELECT' ||
+ element.tagName === 'TEXTAREA' ||
+ (element.contentEditable && element.contentEditable === 'true')
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+ }
+
+ KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`;
+ KeyboardShortcuts.WrappedComponent = WrappedComponent;
+
+ return KeyboardShortcuts;
+}
+
+export default keyboardShortcuts;
diff --git a/frontend/src/Components/withCurrentPage.js b/frontend/src/Components/withCurrentPage.js
new file mode 100644
index 000000000..5e6d9ccf4
--- /dev/null
+++ b/frontend/src/Components/withCurrentPage.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+function withCurrentPage(WrappedComponent) {
+ function CurrentPage(props) {
+ const {
+ history
+ } = props;
+
+ return (
+
+ );
+ }
+
+ CurrentPage.propTypes = {
+ history: PropTypes.object.isRequired
+ };
+
+ return CurrentPage;
+}
+
+export default withCurrentPage;
diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js
new file mode 100644
index 000000000..110da9ab2
--- /dev/null
+++ b/frontend/src/Components/withScrollPosition.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import scrollPositions from 'Store/scrollPositions';
+
+function withScrollPosition(WrappedComponent, scrollPositionKey) {
+ function ScrollPosition(props) {
+ const {
+ history
+ } = props;
+
+ const scrollTop = history.action === 'POP' ?
+ scrollPositions[scrollPositionKey] :
+ 0;
+
+ return (
+
+ );
+ }
+
+ ScrollPosition.propTypes = {
+ history: PropTypes.object.isRequired
+ };
+
+ return ScrollPosition;
+}
+
+export default withScrollPosition;
diff --git a/frontend/src/Content/Fonts/Roboto-Light.ttf b/frontend/src/Content/Fonts/Roboto-Light.ttf
new file mode 100644
index 000000000..94c6bcc67
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.ttf differ
diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff b/frontend/src/Content/Fonts/Roboto-Light.woff
new file mode 100644
index 000000000..ec6bf5749
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff differ
diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff2 b/frontend/src/Content/Fonts/Roboto-Light.woff2
new file mode 100644
index 000000000..288201788
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff2 differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.ttf b/frontend/src/Content/Fonts/Roboto-Regular.ttf
new file mode 100644
index 000000000..8c082c8de
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.ttf differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff b/frontend/src/Content/Fonts/Roboto-Regular.woff
new file mode 100644
index 000000000..464d20623
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff2 b/frontend/src/Content/Fonts/Roboto-Regular.woff2
new file mode 100644
index 000000000..f96619675
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff2 differ
diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot
new file mode 100644
index 000000000..7a03fb512
Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot differ
diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
new file mode 100644
index 000000000..fdd309d71
Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf differ
diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff
new file mode 100644
index 000000000..0289699c0
Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff differ
diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css
new file mode 100644
index 000000000..3db8ae6b0
--- /dev/null
+++ b/frontend/src/Content/Fonts/fonts.css
@@ -0,0 +1,38 @@
+@font-face {
+ font-weight: 300;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Light.woff2?v=1.1.0') format('woff2'), url('Roboto-Light.woff?v=1.2.0') format('woff'), url('Roboto-Light.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Regular.woff2?v=1.2.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: normal;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Ubuntu Mono';
+ src: url('UbuntuMono-Regular.eot?#iefix') format('embedded-opentype'), url('UbuntuMono-Regular.woff') format('woff'), url('UbuntuMono-Regular.ttf') format('truetype');
+}
+
+/*
+ * text-security-disc
+ */
+
+@font-face {
+ font-weight: normal;
+ font-style: normal;
+ font-family: 'text-security-disc';
+ src: url('text-security-disc.woff') format('woff'), url('text-security-disc.ttf') format('truetype');
+}
diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf
new file mode 100644
index 000000000..86038dba8
Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.ttf differ
diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff
new file mode 100644
index 000000000..bc4cc324b
Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.woff differ
diff --git a/frontend/src/Content/Images/404.png b/frontend/src/Content/Images/404.png
new file mode 100644
index 000000000..deeb83f8f
Binary files /dev/null and b/frontend/src/Content/Images/404.png differ
diff --git a/frontend/src/Content/Images/Icons/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/android-chrome-192x192.png
new file mode 100644
index 000000000..3b133f3a6
Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/android-chrome-512x512.png
new file mode 100644
index 000000000..9a117be5a
Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/apple-touch-icon.png b/frontend/src/Content/Images/Icons/apple-touch-icon.png
new file mode 100644
index 000000000..1986ca676
Binary files /dev/null and b/frontend/src/Content/Images/Icons/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/browserconfig.xml b/frontend/src/Content/Images/Icons/browserconfig.xml
new file mode 100644
index 000000000..993924968
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/browserconfig.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+ #00ccff
+
+
+
diff --git a/frontend/src/Content/Images/Icons/favicon-16x16.png b/frontend/src/Content/Images/Icons/favicon-16x16.png
new file mode 100644
index 000000000..94e3bc54e
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-32x32.png b/frontend/src/Content/Images/Icons/favicon-32x32.png
new file mode 100644
index 000000000..52da982d8
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-debug-16x16.png b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png
new file mode 100644
index 000000000..884285bca
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-debug-32x32.png b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png
new file mode 100644
index 000000000..5d3557ea8
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-debug.ico b/frontend/src/Content/Images/Icons/favicon-debug.ico
new file mode 100644
index 000000000..9f80388f3
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug.ico differ
diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico
new file mode 100644
index 000000000..3f63c9d50
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json
new file mode 100644
index 000000000..d14732f60
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/manifest.json
@@ -0,0 +1,18 @@
+{
+ "name": "",
+ "icons": [
+ {
+ "src": "/Content/Images/Icons/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/Content/Images/Icons/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#3a3f51",
+ "background_color": "#3a3f51",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/frontend/src/Content/Images/Icons/mstile-144x144.png b/frontend/src/Content/Images/Icons/mstile-144x144.png
new file mode 100644
index 000000000..86be23dc4
Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/mstile-150x150.png b/frontend/src/Content/Images/Icons/mstile-150x150.png
new file mode 100644
index 000000000..4417102cc
Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/mstile-310x150.png b/frontend/src/Content/Images/Icons/mstile-310x150.png
new file mode 100644
index 000000000..b6308fce5
Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/mstile-310x310.png b/frontend/src/Content/Images/Icons/mstile-310x310.png
new file mode 100644
index 000000000..dc2c2bf6c
Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/mstile-70x70.png b/frontend/src/Content/Images/Icons/mstile-70x70.png
new file mode 100644
index 000000000..fc33b9fe2
Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/safari-pinned-tab.svg b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg
new file mode 100644
index 000000000..6fc7fb969
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg
@@ -0,0 +1,38 @@
+
+
+
+
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+
+
+
+
+
diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png
new file mode 100644
index 000000000..9b1ae7746
Binary files /dev/null and b/frontend/src/Content/Images/error.png differ
diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg
new file mode 100644
index 000000000..b0a721893
--- /dev/null
+++ b/frontend/src/Content/Images/logo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png
new file mode 100644
index 000000000..6a88711c3
Binary files /dev/null and b/frontend/src/Content/Images/poster-dark.png differ
diff --git a/frontend/src/Episode/EpisodeDetailsModal.js b/frontend/src/Episode/EpisodeDetailsModal.js
new file mode 100644
index 000000000..b2f379808
--- /dev/null
+++ b/frontend/src/Episode/EpisodeDetailsModal.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
+
+class EpisodeDetailsModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+EpisodeDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EpisodeDetailsModal;
diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.css b/frontend/src/Episode/EpisodeDetailsModalContent.css
new file mode 100644
index 000000000..450cdce9d
--- /dev/null
+++ b/frontend/src/Episode/EpisodeDetailsModalContent.css
@@ -0,0 +1,44 @@
+.seriesTitle {
+ margin-left: 5px;
+}
+
+.separator {
+ margin: 0 5px;
+}
+
+.tabs {
+ margin-top: -32px;
+}
+
+.tabList {
+ margin: 0;
+ padding: 0;
+}
+
+.tab {
+ position: relative;
+ bottom: -1px;
+ display: inline-block;
+ padding: 6px 12px;
+ border: 1px solid transparent;
+ border-top: none;
+ list-style: none;
+ cursor: pointer;
+}
+
+.selectedTab {
+ border-color: $borderColor;
+ border-radius: 0 0 5px 5px;
+ background-color: rgba(239, 239, 239, 0.4);
+ color: $black;
+}
+
+.tabContent {
+ margin-top: 20px;
+}
+
+.openSeriesButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.js b/frontend/src/Episode/EpisodeDetailsModalContent.js
new file mode 100644
index 000000000..f1b612501
--- /dev/null
+++ b/frontend/src/Episode/EpisodeDetailsModalContent.js
@@ -0,0 +1,218 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
+import episodeEntities from 'Episode/episodeEntities';
+import Button from 'Components/Link/Button';
+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 MonitorToggleButton from 'Components/MonitorToggleButton';
+import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector';
+import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
+import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
+import SeasonEpisodeNumber from './SeasonEpisodeNumber';
+import styles from './EpisodeDetailsModalContent.css';
+
+const tabs = [
+ 'details',
+ 'history',
+ 'search'
+];
+
+class EpisodeDetailsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ selectedTab: props.selectedTab
+ };
+ }
+
+ //
+ // Listeners
+
+ onTabSelect = (index, lastIndex) => {
+ this.setState({ selectedTab: tabs[index] });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ episodeId,
+ episodeEntity,
+ episodeFileId,
+ seriesId,
+ seriesTitle,
+ titleSlug,
+ seriesMonitored,
+ seriesType,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ episodeTitle,
+ airDate,
+ monitored,
+ isSaving,
+ showOpenSeriesButton,
+ startInteractiveSearch,
+ onMonitorEpisodePress,
+ onModalClose
+ } = this.props;
+
+ const seriesLink = `/series/${titleSlug}`;
+
+ return (
+
+
+
+
+
+ {seriesTitle}
+
+
+ -
+
+
+
+ -
+
+ {episodeTitle}
+
+
+
+
+
+
+ Details
+
+
+
+ History
+
+
+
+ Search
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Don't wrap in tabContent so we not have a top margin */}
+
+
+
+
+
+
+ {
+ showOpenSeriesButton &&
+
+ Open Series
+
+ }
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+EpisodeDetailsModalContent.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ episodeEntity: PropTypes.string.isRequired,
+ episodeFileId: PropTypes.number,
+ seriesId: PropTypes.number.isRequired,
+ seriesTitle: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ seriesMonitored: PropTypes.bool.isRequired,
+ seriesType: PropTypes.string.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ airDate: PropTypes.string.isRequired,
+ episodeTitle: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool,
+ showOpenSeriesButton: PropTypes.bool,
+ selectedTab: PropTypes.string.isRequired,
+ startInteractiveSearch: PropTypes.bool.isRequired,
+ onMonitorEpisodePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+EpisodeDetailsModalContent.defaultProps = {
+ selectedTab: 'details',
+ episodeEntity: episodeEntities.EPISODES,
+ startInteractiveSearch: false
+};
+
+export default EpisodeDetailsModalContent;
diff --git a/frontend/src/Episode/EpisodeDetailsModalContentConnector.js b/frontend/src/Episode/EpisodeDetailsModalContentConnector.js
new file mode 100644
index 000000000..5b378fbfb
--- /dev/null
+++ b/frontend/src/Episode/EpisodeDetailsModalContentConnector.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
+import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import episodeEntities from 'Episode/episodeEntities';
+import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeSelector(),
+ createSeriesSelector(),
+ (episode, series) => {
+ const {
+ title: seriesTitle,
+ titleSlug,
+ monitored: seriesMonitored,
+ seriesType
+ } = series;
+
+ return {
+ seriesTitle,
+ titleSlug,
+ seriesMonitored,
+ seriesType,
+ ...episode
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchCancelFetchReleases() {
+ dispatch(cancelFetchReleases());
+ },
+
+ dispatchClearReleases() {
+ dispatch(clearReleases());
+ },
+
+ onMonitorEpisodePress(monitored) {
+ const {
+ episodeId,
+ episodeEntity
+ } = props;
+
+ dispatch(toggleEpisodeMonitored({
+ episodeEntity,
+ episodeId,
+ monitored
+ }));
+ }
+ };
+}
+
+class EpisodeDetailsModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentWillUnmount() {
+ // Clear pending releases here so we can reshow the search
+ // results even after switching tabs.
+
+ this.props.dispatchCancelFetchReleases();
+ this.props.dispatchClearReleases();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearReleases,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EpisodeDetailsModalContentConnector.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ episodeEntity: PropTypes.string.isRequired,
+ seriesId: PropTypes.number.isRequired,
+ dispatchCancelFetchReleases: PropTypes.func.isRequired,
+ dispatchClearReleases: PropTypes.func.isRequired
+};
+
+EpisodeDetailsModalContentConnector.defaultProps = {
+ episodeEntity: episodeEntities.EPISODES
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);
diff --git a/frontend/src/Episode/EpisodeLanguage.js b/frontend/src/Episode/EpisodeLanguage.js
new file mode 100644
index 000000000..52c8b3390
--- /dev/null
+++ b/frontend/src/Episode/EpisodeLanguage.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Label from 'Components/Label';
+import { kinds } from 'Helpers/Props';
+
+function EpisodeLanguage(props) {
+ const {
+ className,
+ language,
+ isCutoffNotMet
+ } = props;
+
+ if (!language) {
+ return null;
+ }
+
+ return (
+
+ {language.name}
+
+ );
+}
+
+EpisodeLanguage.propTypes = {
+ className: PropTypes.string,
+ language: PropTypes.object,
+ isCutoffNotMet: PropTypes.bool
+};
+
+EpisodeLanguage.defaultProps = {
+ isCutoffNotMet: true
+};
+
+export default EpisodeLanguage;
diff --git a/frontend/src/Episode/EpisodeNumber.css b/frontend/src/Episode/EpisodeNumber.css
new file mode 100644
index 000000000..1c5072d02
--- /dev/null
+++ b/frontend/src/Episode/EpisodeNumber.css
@@ -0,0 +1,7 @@
+.absoluteEpisodeNumber {
+ margin-left: 5px;
+}
+
+.warning {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Episode/EpisodeNumber.js b/frontend/src/Episode/EpisodeNumber.js
new file mode 100644
index 000000000..f8183be8a
--- /dev/null
+++ b/frontend/src/Episode/EpisodeNumber.js
@@ -0,0 +1,138 @@
+import PropTypes from 'prop-types';
+import React, { Fragment } from 'react';
+import padNumber from 'Utilities/Number/padNumber';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+import SceneInfo from './SceneInfo';
+import styles from './EpisodeNumber.css';
+
+function getAlternateTitles(seasonNumber, sceneSeasonNumber, alternateTitles) {
+ return alternateTitles.filter((alternateTitle) => {
+ if (sceneSeasonNumber && sceneSeasonNumber === alternateTitle.sceneSeasonNumber) {
+ return true;
+ }
+
+ return seasonNumber === alternateTitle.seasonNumber;
+ });
+}
+
+function EpisodeNumber(props) {
+ const {
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ unverifiedSceneNumbering,
+ alternateTitles: seriesAlternateTitles,
+ seriesType,
+ showSeasonNumber
+ } = props;
+
+ const alternateTitles = getAlternateTitles(seasonNumber, sceneSeasonNumber, seriesAlternateTitles);
+
+ const hasSceneInformation = sceneSeasonNumber !== undefined ||
+ sceneEpisodeNumber !== undefined ||
+ (seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) ||
+ !!alternateTitles.length;
+
+ return (
+
+ {
+ hasSceneInformation ?
+
+ {
+ showSeasonNumber && seasonNumber != null &&
+
+ {seasonNumber}x
+
+ }
+
+ {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
+
+ {
+ seriesType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ }
+ title="Scene Information"
+ body={
+
+ }
+ position={tooltipPositions.RIGHT}
+ /> :
+
+ {
+ showSeasonNumber && seasonNumber != null &&
+
+ {seasonNumber}x
+
+ }
+
+ {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
+
+ {
+ seriesType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ }
+
+ {
+ unverifiedSceneNumbering &&
+
+ }
+
+ {
+ seriesType === 'anime' && !absoluteEpisodeNumber &&
+
+ }
+
+ );
+}
+
+EpisodeNumber.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ unverifiedSceneNumbering: PropTypes.bool.isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ seriesType: PropTypes.string,
+ showSeasonNumber: PropTypes.bool.isRequired
+};
+
+EpisodeNumber.defaultProps = {
+ unverifiedSceneNumbering: false,
+ alternateTitles: [],
+ showSeasonNumber: false
+};
+
+export default EpisodeNumber;
diff --git a/frontend/src/Episode/EpisodeQuality.js b/frontend/src/Episode/EpisodeQuality.js
new file mode 100644
index 000000000..65a3f3dbc
--- /dev/null
+++ b/frontend/src/Episode/EpisodeQuality.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function getTooltip(title, quality, size) {
+ const revision = quality.revision;
+
+ if (revision.real && revision.real > 0) {
+ title += ' [REAL]';
+ }
+
+ if (revision.version && revision.version > 1) {
+ title += ' [PROPER]';
+ }
+
+ if (size) {
+ title += ` - ${formatBytes(size)}`;
+ }
+
+ return title;
+}
+
+function EpisodeQuality(props) {
+ const {
+ className,
+ title,
+ quality,
+ size,
+ isCutoffNotMet
+ } = props;
+
+ return (
+
+ {quality.quality.name}
+
+ );
+}
+
+EpisodeQuality.propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string,
+ quality: PropTypes.object.isRequired,
+ size: PropTypes.number,
+ isCutoffNotMet: PropTypes.bool
+};
+
+EpisodeQuality.defaultProps = {
+ title: ''
+};
+
+export default EpisodeQuality;
diff --git a/frontend/src/Episode/EpisodeSearchCell.css b/frontend/src/Episode/EpisodeSearchCell.css
new file mode 100644
index 000000000..5e99b51eb
--- /dev/null
+++ b/frontend/src/Episode/EpisodeSearchCell.css
@@ -0,0 +1,6 @@
+.episodeSearchCell {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+ white-space: nowrap;
+}
diff --git a/frontend/src/Episode/EpisodeSearchCell.js b/frontend/src/Episode/EpisodeSearchCell.js
new file mode 100644
index 000000000..d0f8c4114
--- /dev/null
+++ b/frontend/src/Episode/EpisodeSearchCell.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import EpisodeDetailsModal from './EpisodeDetailsModal';
+import styles from './EpisodeSearchCell.css';
+
+class EpisodeSearchCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onManualSearchPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ episodeId,
+ seriesId,
+ episodeTitle,
+ isSearching,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+EpisodeSearchCell.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ seriesId: PropTypes.number.isRequired,
+ episodeTitle: PropTypes.string.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+export default EpisodeSearchCell;
diff --git a/frontend/src/Episode/EpisodeSearchCellConnector.js b/frontend/src/Episode/EpisodeSearchCellConnector.js
new file mode 100644
index 000000000..e4df0a324
--- /dev/null
+++ b/frontend/src/Episode/EpisodeSearchCellConnector.js
@@ -0,0 +1,50 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import EpisodeSearchCell from './EpisodeSearchCell';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { episodeId }) => episodeId,
+ (state, { sceneSeasonNumber }) => sceneSeasonNumber,
+ createSeriesSelector(),
+ createCommandsSelector(),
+ (episodeId, sceneSeasonNumber, series, commands) => {
+ const isSearching = commands.some((command) => {
+ const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
+
+ if (!episodeSearch) {
+ return false;
+ }
+
+ return (
+ isCommandExecuting(command) &&
+ command.body.episodeIds.indexOf(episodeId) > -1
+ );
+ });
+
+ return {
+ seriesMonitored: series.monitored,
+ seriesType: series.seriesType,
+ isSearching
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSearchPress(name, path) {
+ dispatch(executeCommand({
+ name: commandNames.EPISODE_SEARCH,
+ episodeIds: [props.episodeId]
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);
diff --git a/frontend/src/Episode/EpisodeStatus.css b/frontend/src/Episode/EpisodeStatus.css
new file mode 100644
index 000000000..3833887df
--- /dev/null
+++ b/frontend/src/Episode/EpisodeStatus.css
@@ -0,0 +1,4 @@
+.center {
+ display: flex;
+ justify-content: center;
+}
diff --git a/frontend/src/Episode/EpisodeStatus.js b/frontend/src/Episode/EpisodeStatus.js
new file mode 100644
index 000000000..ee8ff9dad
--- /dev/null
+++ b/frontend/src/Episode/EpisodeStatus.js
@@ -0,0 +1,128 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import isBefore from 'Utilities/Date/isBefore';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import ProgressBar from 'Components/ProgressBar';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+import EpisodeQuality from './EpisodeQuality';
+import styles from './EpisodeStatus.css';
+
+function EpisodeStatus(props) {
+ const {
+ airDateUtc,
+ monitored,
+ grabbed,
+ queueItem,
+ episodeFile
+ } = props;
+
+ const hasEpisodeFile = !!episodeFile;
+ const isQueued = !!queueItem;
+ const hasAired = isBefore(airDateUtc);
+
+ if (isQueued) {
+ const {
+ sizeleft,
+ size
+ } = queueItem;
+
+ const progress = (100 - sizeleft / size * 100);
+
+ return (
+
+
+ }
+ />
+
+ );
+ }
+
+ if (grabbed) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasEpisodeFile) {
+ const quality = episodeFile.quality;
+ const isCutoffNotMet = episodeFile.qualityCutoffNotMet;
+
+ return (
+
+
+
+ );
+ }
+
+ if (!airDateUtc) {
+ return (
+
+
+
+ );
+ }
+
+ if (!monitored) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasAired) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+EpisodeStatus.propTypes = {
+ airDateUtc: PropTypes.string,
+ monitored: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ episodeFile: PropTypes.object
+};
+
+export default EpisodeStatus;
diff --git a/frontend/src/Episode/EpisodeStatusConnector.js b/frontend/src/Episode/EpisodeStatusConnector.js
new file mode 100644
index 000000000..7169dd31b
--- /dev/null
+++ b/frontend/src/Episode/EpisodeStatusConnector.js
@@ -0,0 +1,53 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import EpisodeStatus from './EpisodeStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeSelector(),
+ createQueueItemSelector(),
+ createEpisodeFileSelector(),
+ (episode, queueItem, episodeFile) => {
+ const result = _.pick(episode, [
+ 'airDateUtc',
+ 'monitored',
+ 'grabbed'
+ ]);
+
+ result.queueItem = queueItem;
+ result.episodeFile = episodeFile;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+};
+
+class EpisodeStatusConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EpisodeStatusConnector.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ episodeFileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);
diff --git a/frontend/src/Episode/EpisodeTitleLink.css b/frontend/src/Episode/EpisodeTitleLink.css
new file mode 100644
index 000000000..6022be8a4
--- /dev/null
+++ b/frontend/src/Episode/EpisodeTitleLink.css
@@ -0,0 +1,8 @@
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ &:hover {
+ color: $linkHoverColor;
+ text-decoration: underline;
+ }
+}
diff --git a/frontend/src/Episode/EpisodeTitleLink.js b/frontend/src/Episode/EpisodeTitleLink.js
new file mode 100644
index 000000000..eac76eaae
--- /dev/null
+++ b/frontend/src/Episode/EpisodeTitleLink.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
+import styles from './EpisodeTitleLink.css';
+
+class EpisodeTitleLink extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onLinkPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ episodeTitle,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {episodeTitle}
+
+
+
+
+ );
+ }
+}
+
+EpisodeTitleLink.propTypes = {
+ episodeTitle: PropTypes.string.isRequired
+};
+
+EpisodeTitleLink.defaultProps = {
+ showSeriesButton: false
+};
+
+export default EpisodeTitleLink;
diff --git a/frontend/src/Episode/History/EpisodeHistory.js b/frontend/src/Episode/History/EpisodeHistory.js
new file mode 100644
index 000000000..5a7007ba8
--- /dev/null
+++ b/frontend/src/Episode/History/EpisodeHistory.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import EpisodeHistoryRow from './EpisodeHistoryRow';
+
+const columns = [
+ {
+ name: 'eventType',
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isVisible: true
+ },
+ {
+ name: 'details',
+ label: 'Details',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: 'Actions',
+ isVisible: true
+ }
+];
+
+class EpisodeHistory extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onMarkAsFailedPress
+ } = this.props;
+
+ const hasItems = !!items.length;
+
+ if (isFetching) {
+ return (
+
+ );
+ }
+
+ if (!isFetching && !!error) {
+ return (
+ Unable to load episode history.
+ );
+ }
+
+ if (isPopulated && !hasItems && !error) {
+ return (
+ No episode history.
+ );
+ }
+
+ if (isPopulated && hasItems && !error) {
+ return (
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+ }
+
+ return null;
+ }
+}
+
+EpisodeHistory.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+EpisodeHistory.defaultProps = {
+ selectedTab: 'details'
+};
+
+export default EpisodeHistory;
diff --git a/frontend/src/Episode/History/EpisodeHistoryConnector.js b/frontend/src/Episode/History/EpisodeHistoryConnector.js
new file mode 100644
index 000000000..cf28b79dc
--- /dev/null
+++ b/frontend/src/Episode/History/EpisodeHistoryConnector.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchEpisodeHistory, clearEpisodeHistory, episodeHistoryMarkAsFailed } from 'Store/Actions/episodeHistoryActions';
+import EpisodeHistory from './EpisodeHistory';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.episodeHistory,
+ (episodeHistory) => {
+ return episodeHistory;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchEpisodeHistory,
+ clearEpisodeHistory,
+ episodeHistoryMarkAsFailed
+};
+
+class EpisodeHistoryConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId });
+ }
+
+ componentWillUnmount() {
+ this.props.clearEpisodeHistory();
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = (historyId) => {
+ this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EpisodeHistoryConnector.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ fetchEpisodeHistory: PropTypes.func.isRequired,
+ clearEpisodeHistory: PropTypes.func.isRequired,
+ episodeHistoryMarkAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector);
diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.css b/frontend/src/Episode/History/EpisodeHistoryRow.css
new file mode 100644
index 000000000..8c3fb8272
--- /dev/null
+++ b/frontend/src/Episode/History/EpisodeHistoryRow.css
@@ -0,0 +1,6 @@
+.details,
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 65px;
+}
diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.js b/frontend/src/Episode/History/EpisodeHistoryRow.js
new file mode 100644
index 000000000..409449f3b
--- /dev/null
+++ b/frontend/src/Episode/History/EpisodeHistoryRow.js
@@ -0,0 +1,163 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
+import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
+import styles from './EpisodeHistoryRow.css';
+
+function getTitle(eventType) {
+ switch (eventType) {
+ case 'grabbed': return 'Grabbed';
+ case 'seriesFolderImported': return 'Series Folder Imported';
+ case 'downloadFolderImported': return 'Download Folder Imported';
+ case 'downloadFailed': return 'Download Failed';
+ case 'episodeFileDeleted': return 'Episode File Deleted';
+ case 'episodeFileRenamed': return 'Episode File Renamed';
+ default: return 'Unknown';
+ }
+}
+
+class EpisodeHistoryRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMarkAsFailedModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = () => {
+ this.setState({ isMarkAsFailedModalOpen: true });
+ }
+
+ onConfirmMarkAsFailed = () => {
+ this.props.onMarkAsFailedPress(this.props.id);
+ this.setState({ isMarkAsFailedModalOpen: false });
+ }
+
+ onMarkAsFailedModalClose = () => {
+ this.setState({ isMarkAsFailedModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ eventType,
+ sourceTitle,
+ language,
+ languageCutoffNotMet,
+ quality,
+ qualityCutoffNotMet,
+ date,
+ data
+ } = this.props;
+
+ const {
+ isMarkAsFailedModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+ {sourceTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ title={getTitle(eventType)}
+ body={
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+EpisodeHistoryRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ language: PropTypes.object.isRequired,
+ languageCutoffNotMet: PropTypes.bool.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+export default EpisodeHistoryRow;
diff --git a/frontend/src/Episode/SceneInfo.css b/frontend/src/Episode/SceneInfo.css
new file mode 100644
index 000000000..3efb78509
--- /dev/null
+++ b/frontend/src/Episode/SceneInfo.css
@@ -0,0 +1,17 @@
+.descriptionList {
+ composes: descriptionList from 'Components/DescriptionList/DescriptionList.css';
+
+ margin-right: 10px;
+}
+
+.title {
+ composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css';
+
+ width: 80px;
+}
+
+.description {
+ composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
+
+ margin-left: 100px;
+}
diff --git a/frontend/src/Episode/SceneInfo.js b/frontend/src/Episode/SceneInfo.js
new file mode 100644
index 000000000..3eef9351f
--- /dev/null
+++ b/frontend/src/Episode/SceneInfo.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './SceneInfo.css';
+
+function SceneInfo(props) {
+ const {
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ alternateTitles,
+ seriesType
+ } = props;
+
+ return (
+
+ {
+ sceneSeasonNumber !== undefined &&
+
+ }
+
+ {
+ sceneEpisodeNumber !== undefined &&
+
+ }
+
+ {
+ seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
+
+ }
+
+ {
+ !!alternateTitles.length &&
+
+ {
+ alternateTitles.map((alternateTitle) => {
+ return (
+
+ {alternateTitle.title}
+
+ );
+ })
+ }
+
+ }
+ />
+ }
+
+ );
+}
+
+SceneInfo.propTypes = {
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ seriesType: PropTypes.string
+};
+
+export default SceneInfo;
diff --git a/frontend/src/Episode/Search/EpisodeSearch.css b/frontend/src/Episode/Search/EpisodeSearch.css
new file mode 100644
index 000000000..2f7ddfd19
--- /dev/null
+++ b/frontend/src/Episode/Search/EpisodeSearch.css
@@ -0,0 +1,16 @@
+.buttonContainer {
+ display: flex;
+ justify-content: center;
+
+ margin-top: 10px;
+}
+
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ width: 300px;
+}
+
+.buttonIcon {
+ margin-right: 5px;
+}
diff --git a/frontend/src/Episode/Search/EpisodeSearch.js b/frontend/src/Episode/Search/EpisodeSearch.js
new file mode 100644
index 000000000..f3ab8fdec
--- /dev/null
+++ b/frontend/src/Episode/Search/EpisodeSearch.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import styles from './EpisodeSearch.css';
+
+function EpisodeSearch(props) {
+ const {
+ onQuickSearchPress,
+ onInteractiveSearchPress
+ } = props;
+
+ return (
+
+
+
+
+
+ Quick Search
+
+
+
+
+
+
+
+ Interactive Search
+
+
+
+ );
+}
+
+EpisodeSearch.propTypes = {
+ onQuickSearchPress: PropTypes.func.isRequired,
+ onInteractiveSearchPress: PropTypes.func.isRequired
+};
+
+export default EpisodeSearch;
diff --git a/frontend/src/Episode/Search/EpisodeSearchConnector.js b/frontend/src/Episode/Search/EpisodeSearchConnector.js
new file mode 100644
index 000000000..c25eecc4f
--- /dev/null
+++ b/frontend/src/Episode/Search/EpisodeSearchConnector.js
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import EpisodeSearch from './EpisodeSearch';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.releases,
+ (releases) => {
+ return {
+ isPopulated: releases.isPopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class EpisodeSearchConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isInteractiveSearchOpen: props.startInteractiveSearch
+ };
+ }
+
+ componentDidMount() {
+ if (this.props.isPopulated) {
+ this.setState({ isInteractiveSearchOpen: true });
+ }
+ }
+
+ //
+ // Listeners
+
+ onQuickSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.EPISODE_SEARCH,
+ episodeIds: [this.props.episodeId]
+ });
+
+ this.props.onModalClose();
+ }
+
+ onInteractiveSearchPress = () => {
+ this.setState({ isInteractiveSearchOpen: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ const { episodeId } = this.props;
+
+ if (this.state.isInteractiveSearchOpen) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+EpisodeSearchConnector.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ startInteractiveSearch: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeSearchConnector);
diff --git a/frontend/src/Episode/SeasonEpisodeNumber.js b/frontend/src/Episode/SeasonEpisodeNumber.js
new file mode 100644
index 000000000..e4d391002
--- /dev/null
+++ b/frontend/src/Episode/SeasonEpisodeNumber.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import EpisodeNumber from './EpisodeNumber';
+
+function SeasonEpisodeNumber(props) {
+ const {
+ airDate,
+ seriesType,
+ ...otherProps
+ } = props;
+
+ if (seriesType === 'daily' && airDate) {
+ return (
+ {airDate}
+ );
+ }
+
+ return (
+
+ );
+}
+
+SeasonEpisodeNumber.propTypes = {
+ airDate: PropTypes.string,
+ seriesType: PropTypes.string
+};
+
+export default SeasonEpisodeNumber;
diff --git a/frontend/src/Episode/Summary/EpisodeAiring.js b/frontend/src/Episode/Summary/EpisodeAiring.js
new file mode 100644
index 000000000..54ca64325
--- /dev/null
+++ b/frontend/src/Episode/Summary/EpisodeAiring.js
@@ -0,0 +1,86 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatTime from 'Utilities/Date/formatTime';
+import isInNextWeek from 'Utilities/Date/isInNextWeek';
+import isToday from 'Utilities/Date/isToday';
+import isTomorrow from 'Utilities/Date/isTomorrow';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function EpisodeAiring(props) {
+ const {
+ airDateUtc,
+ network,
+ shortDateFormat,
+ showRelativeDates,
+ timeFormat
+ } = props;
+
+ const networkLabel = (
+
+ {network}
+
+ );
+
+ if (!airDateUtc) {
+ return (
+
+ TBA on {networkLabel}
+
+ );
+ }
+
+ const time = formatTime(airDateUtc, timeFormat);
+
+ if (!showRelativeDates) {
+ return (
+
+ {moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel}
+
+ );
+ }
+
+ if (isToday(airDateUtc)) {
+ return (
+
+ {time} on {networkLabel}
+
+ );
+ }
+
+ if (isTomorrow(airDateUtc)) {
+ return (
+
+ Tomorrow at {time} on {networkLabel}
+
+ );
+ }
+
+ if (isInNextWeek(airDateUtc)) {
+ return (
+
+ {moment(airDateUtc).format('dddd')} at {time} on {networkLabel}
+
+ );
+ }
+
+ return (
+
+ {moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel}
+
+ );
+}
+
+EpisodeAiring.propTypes = {
+ airDateUtc: PropTypes.string.isRequired,
+ network: PropTypes.string.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default EpisodeAiring;
diff --git a/frontend/src/Episode/Summary/EpisodeAiringConnector.js b/frontend/src/Episode/Summary/EpisodeAiringConnector.js
new file mode 100644
index 000000000..508467efb
--- /dev/null
+++ b/frontend/src/Episode/Summary/EpisodeAiringConnector.js
@@ -0,0 +1,20 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import EpisodeAiring from './EpisodeAiring';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return _.pick(uiSettings, [
+ 'shortDateFormat',
+ 'showRelativeDates',
+ 'timeFormat'
+ ]);
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(EpisodeAiring);
diff --git a/frontend/src/Episode/Summary/EpisodeSummary.css b/frontend/src/Episode/Summary/EpisodeSummary.css
new file mode 100644
index 000000000..f8238ed8a
--- /dev/null
+++ b/frontend/src/Episode/Summary/EpisodeSummary.css
@@ -0,0 +1,48 @@
+.infoTitle {
+ display: inline-block;
+ width: 100px;
+ font-weight: bold;
+}
+
+.overview,
+.files {
+ margin-top: 20px;
+}
+
+.filesHeader {
+ display: flex;
+ font-weight: bold;
+}
+
+.filesHeader {
+ display: flex;
+ margin-bottom: 10px;
+ border-bottom: 1px solid $borderColor;
+}
+
+.fileRow {
+ display: flex;
+}
+
+.path {
+ @add-mixin truncate;
+
+ flex: 1 0 1px;
+}
+
+.size,
+.quality {
+ flex: 0 0 125px;
+}
+
+.actions {
+ flex: 0 0 40px;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .size,
+ .quality {
+ flex: 0 0 80px;
+ }
+}
diff --git a/frontend/src/Episode/Summary/EpisodeSummary.js b/frontend/src/Episode/Summary/EpisodeSummary.js
new file mode 100644
index 000000000..ef1b7cc67
--- /dev/null
+++ b/frontend/src/Episode/Summary/EpisodeSummary.js
@@ -0,0 +1,183 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Popover from 'Components/Tooltip/Popover';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import EpisodeAiringConnector from './EpisodeAiringConnector';
+import MediaInfo from './MediaInfo';
+import styles from './EpisodeSummary.css';
+
+class EpisodeSummary extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRemoveEpisodeFileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRemoveEpisodeFilePress = () => {
+ this.setState({ isRemoveEpisodeFileModalOpen: true });
+ }
+
+ onConfirmRemoveEpisodeFile = () => {
+ this.props.onDeleteEpisodeFile();
+ this.setState({ isRemoveEpisodeFileModalOpen: false });
+ }
+
+ onRemoveEpisodeFileModalClose = () => {
+ this.setState({ isRemoveEpisodeFileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ qualityProfileId,
+ network,
+ overview,
+ airDateUtc,
+ mediaInfo,
+ path,
+ size,
+ quality,
+ qualityCutoffNotMet
+ } = this.props;
+
+ const hasOverview = !!overview;
+
+ return (
+
+
+ Airs
+
+
+
+
+
+ Quality Profile
+
+
+
+
+
+
+
+ {
+ hasOverview ?
+ overview :
+ 'No episode overview.'
+ }
+
+
+ {
+ path &&
+
+
+
+ Path
+
+
+
+ Size
+
+
+
+ Quality
+
+
+
+
+
+
+
+ {path}
+
+
+
+ {formatBytes(size)}
+
+
+
+
+
+
+
+
+ }
+ title="Media Info"
+ body={
}
+ position={tooltipPositions.LEFT}
+ />
+
+
+
+
+
+ }
+
+
+
+ );
+ }
+}
+
+EpisodeSummary.propTypes = {
+ episodeFileId: PropTypes.number.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ network: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ airDateUtc: PropTypes.string.isRequired,
+ mediaInfo: PropTypes.object,
+ path: PropTypes.string,
+ size: PropTypes.number,
+ quality: PropTypes.object,
+ qualityCutoffNotMet: PropTypes.bool,
+ onDeleteEpisodeFile: PropTypes.func.isRequired
+};
+
+export default EpisodeSummary;
diff --git a/frontend/src/Episode/Summary/EpisodeSummaryConnector.js b/frontend/src/Episode/Summary/EpisodeSummaryConnector.js
new file mode 100644
index 000000000..e17f7b750
--- /dev/null
+++ b/frontend/src/Episode/Summary/EpisodeSummaryConnector.js
@@ -0,0 +1,59 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteEpisodeFile } from 'Store/Actions/episodeFileActions';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import EpisodeSummary from './EpisodeSummary';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ createEpisodeSelector(),
+ createEpisodeFileSelector(),
+ (series, episode, episodeFile = {}) => {
+ const {
+ qualityProfileId,
+ network
+ } = series;
+
+ const {
+ airDateUtc,
+ overview
+ } = episode;
+
+ const {
+ mediaInfo,
+ path,
+ size,
+ quality,
+ qualityCutoffNotMet
+ } = episodeFile;
+
+ return {
+ network,
+ qualityProfileId,
+ airDateUtc,
+ overview,
+ mediaInfo,
+ path,
+ size,
+ quality,
+ qualityCutoffNotMet
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDeleteEpisodeFile() {
+ dispatch(deleteEpisodeFile({
+ id: props.episodeFileId,
+ episodeEntity: props.episodeEntity
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummary);
diff --git a/frontend/src/Episode/Summary/MediaInfo.js b/frontend/src/Episode/Summary/MediaInfo.js
new file mode 100644
index 000000000..af023266b
--- /dev/null
+++ b/frontend/src/Episode/Summary/MediaInfo.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+
+function MediaInfo(props) {
+ return (
+
+ {
+ Object.keys(props).map((key) => {
+ const title = key
+ .replace(/([A-Z])/g, ' $1')
+ .replace(/^./, (str) => str.toUpperCase());
+
+ const value = props[key];
+
+ if (!value) {
+ return null;
+ }
+
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+export default MediaInfo;
diff --git a/frontend/src/Episode/episodeEntities.js b/frontend/src/Episode/episodeEntities.js
new file mode 100644
index 000000000..fe21d4ed0
--- /dev/null
+++ b/frontend/src/Episode/episodeEntities.js
@@ -0,0 +1,13 @@
+export const CALENDAR = 'calendar';
+export const EPISODES = 'episodes';
+export const INTERACTIVE_IMPORT = 'interactiveImport.episodes';
+export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
+export const WANTED_MISSING = 'wanted.missing';
+
+export default {
+ CALENDAR,
+ EPISODES,
+ INTERACTIVE_IMPORT,
+ WANTED_CUTOFF_UNMET,
+ WANTED_MISSING
+};
diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js
new file mode 100644
index 000000000..35d00caf2
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EpisodeFileEditorModalContentConnector from './EpisodeFileEditorModalContentConnector';
+
+function EpisodeFileEditorModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+EpisodeFileEditorModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EpisodeFileEditorModal;
diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css
new file mode 100644
index 000000000..49e946826
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css
@@ -0,0 +1,8 @@
+.actions {
+ display: flex;
+ margin-right: auto;
+}
+
+.selectInput {
+ margin-left: 10px;
+}
diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js
new file mode 100644
index 000000000..1d65457d7
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js
@@ -0,0 +1,285 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { kinds } from 'Helpers/Props';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import SelectInput from 'Components/Form/SelectInput';
+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 SeasonNumber from 'Season/SeasonNumber';
+import EpisodeFileEditorRow from './EpisodeFileEditorRow';
+import styles from './EpisodeFileEditorModalContent.css';
+
+const columns = [
+ {
+ name: 'episodeNumber',
+ label: 'Episode',
+ isVisible: true
+ },
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isVisible: true
+ },
+ {
+ name: 'airDateUtc',
+ label: 'Air Date',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ }
+];
+
+class EpisodeFileEditorModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.items !== this.props.items) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ const selectedIds = getSelectedIds(this.state.selectedState);
+
+ return selectedIds.reduce((acc, id) => {
+ const matchingItem = this.props.items.find((item) => item.id === id);
+
+ if (matchingItem && !acc.includes(matchingItem.episodeFileId)) {
+ acc.push(matchingItem.episodeFileId);
+ }
+
+ return acc;
+ }, []);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onDeletePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ }
+
+ onConfirmDelete = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ this.props.onDeletePress(this.getSelectedIds());
+ }
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ }
+
+ onLanguageChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onLanguageChange(selectedIds, parseInt(value));
+ }
+
+ onQualityChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onQualityChange(selectedIds, parseInt(value));
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ seasonNumber,
+ isDeleting,
+ items,
+ languages,
+ qualities,
+ seriesType,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ const languageOptions = _.reduceRight(languages, (acc, language) => {
+ acc.push({
+ key: language.id,
+ value: language.name
+ });
+
+ return acc;
+ }, [{ key: 'selectLanguage', value: 'Select Language', disabled: true }]);
+
+ const qualityOptions = _.reduceRight(qualities, (acc, quality) => {
+ acc.push({
+ key: quality.id,
+ value: quality.name
+ });
+
+ return acc;
+ }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]);
+
+ const hasSelectedFiles = this.getSelectedIds().length > 0;
+
+ return (
+
+
+ Manage Episodes {seasonNumber != null && }
+
+
+
+ {
+ !items.length &&
+
+ No episode files to manage.
+
+ }
+
+ {
+ !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+
+ );
+ }
+}
+
+EpisodeFileEditorModalContent.propTypes = {
+ seasonNumber: PropTypes.number,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ seriesType: PropTypes.string.isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onLanguageChange: PropTypes.func.isRequired,
+ onQualityChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EpisodeFileEditorModalContent;
diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js
new file mode 100644
index 000000000..c20e752e3
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js
@@ -0,0 +1,151 @@
+/* eslint max-params: 0 */
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import getQualities from 'Utilities/Quality/getQualities';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import { deleteEpisodeFiles, updateEpisodeFiles } from 'Store/Actions/episodeFileActions';
+import { fetchLanguageProfileSchema, fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import EpisodeFileEditorModalContent from './EpisodeFileEditorModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seasonNumber }) => seasonNumber,
+ (state) => state.episodes,
+ (state) => state.episodeFiles,
+ (state) => state.settings.languageProfiles.schema,
+ (state) => state.settings.qualityProfiles.schema,
+ createSeriesSelector(),
+ (
+ seasonNumber,
+ episodes,
+ episodeFiles,
+ languageProfilesSchema,
+ qualityProfileSchema,
+ series
+ ) => {
+ const filtered = _.filter(episodes.items, (episode) => {
+ if (seasonNumber >= 0 && episode.seasonNumber !== seasonNumber) {
+ return false;
+ }
+
+ if (!episode.episodeFileId) {
+ return false;
+ }
+
+ return _.some(episodeFiles.items, { id: episode.episodeFileId });
+ });
+
+ const sorted = _.orderBy(filtered, ['seasonNumber', 'episodeNumber'], ['desc', 'desc']);
+
+ const items = _.map(sorted, (episode) => {
+ const episodeFile = _.find(episodeFiles.items, { id: episode.episodeFileId });
+
+ return {
+ relativePath: episodeFile.relativePath,
+ language: episodeFile.language,
+ quality: episodeFile.quality,
+ ...episode
+ };
+ });
+
+ const languages = _.map(languageProfilesSchema.languages, 'language');
+ const qualities = getQualities(qualityProfileSchema.items);
+
+ return {
+ items,
+ seriesType: series.seriesType,
+ isDeleting: episodeFiles.isDeleting,
+ isSaving: episodeFiles.isSaving,
+ languages,
+ qualities
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchLanguageProfileSchema(name, path) {
+ dispatch(fetchLanguageProfileSchema());
+ },
+
+ dispatchFetchQualityProfileSchema(name, path) {
+ dispatch(fetchQualityProfileSchema());
+ },
+
+ dispatchUpdateEpisodeFiles(updateProps) {
+ dispatch(updateEpisodeFiles(updateProps));
+ },
+
+ onDeletePress(episodeFileIds) {
+ dispatch(deleteEpisodeFiles({ episodeFileIds }));
+ }
+ };
+}
+
+class EpisodeFileEditorModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchLanguageProfileSchema();
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+
+ //
+ // Render
+
+ //
+ // Listeners
+
+ onLanguageChange = (episodeFileIds, languageId) => {
+ const language = _.find(this.props.languages, { id: languageId });
+
+ this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, language });
+ }
+
+ onQualityChange = (episodeFileIds, qualityId) => {
+ const quality = {
+ quality: _.find(this.props.qualities, { id: qualityId }),
+ revision: {
+ version: 1,
+ real: 0
+ }
+ };
+
+ this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, quality });
+ }
+
+ render() {
+ const {
+ dispatchFetchLanguageProfileSchema,
+ dispatchFetchQualityProfileSchema,
+ dispatchUpdateEpisodeFiles,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EpisodeFileEditorModalContentConnector.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
+ dispatchUpdateEpisodeFiles: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeFileEditorModalContentConnector);
diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css
new file mode 100644
index 000000000..f86e1de6b
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css
@@ -0,0 +1,3 @@
+.absoluteEpisodeNumber {
+ margin-left: 5px;
+}
diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js
new file mode 100644
index 000000000..62cedb8af
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import padNumber from 'Utilities/Number/padNumber';
+import Label from 'Components/Label';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import styles from './EpisodeFileEditorRow';
+
+function EpisodeFileEditorRow(props) {
+ const {
+ id,
+ seriesType,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ relativePath,
+ airDateUtc,
+ language,
+ quality,
+ isSelected,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ {
+ seriesType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+
+
+ {relativePath}
+
+
+
+
+
+
+ {language.name}
+
+
+
+
+
+
+
+ );
+}
+
+EpisodeFileEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ seriesType: PropTypes.string.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ relativePath: PropTypes.string.isRequired,
+ airDateUtc: PropTypes.string.isRequired,
+ language: PropTypes.object.isRequired,
+ quality: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default EpisodeFileEditorRow;
diff --git a/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js b/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js
new file mode 100644
index 000000000..38713c011
--- /dev/null
+++ b/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeFileSelector(),
+ (episodeFile) => {
+ return {
+ language: episodeFile ? episodeFile.language : undefined
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(EpisodeLanguage);
diff --git a/frontend/src/EpisodeFile/MediaInfo.js b/frontend/src/EpisodeFile/MediaInfo.js
new file mode 100644
index 000000000..75b264d58
--- /dev/null
+++ b/frontend/src/EpisodeFile/MediaInfo.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import * as mediaInfoTypes from './mediaInfoTypes';
+
+function MediaInfo(props) {
+ const {
+ type,
+ audioChannels,
+ audioCodec,
+ videoCodec
+ } = props;
+
+ if (type === mediaInfoTypes.AUDIO) {
+ return (
+
+ {
+ !!audioCodec &&
+ audioCodec
+ }
+
+ {
+ !!audioCodec && !!audioChannels &&
+ ' - '
+ }
+
+ {
+ !!audioChannels &&
+ audioChannels.toFixed(1)
+ }
+
+ );
+ }
+
+ if (type === mediaInfoTypes.VIDEO) {
+ return (
+
+ {videoCodec}
+
+ );
+ }
+
+ return null;
+}
+
+MediaInfo.propTypes = {
+ type: PropTypes.string.isRequired,
+ audioChannels: PropTypes.number,
+ audioCodec: PropTypes.string,
+ videoCodec: PropTypes.string
+};
+
+export default MediaInfo;
diff --git a/frontend/src/EpisodeFile/MediaInfoConnector.js b/frontend/src/EpisodeFile/MediaInfoConnector.js
new file mode 100644
index 000000000..bbb963cf4
--- /dev/null
+++ b/frontend/src/EpisodeFile/MediaInfoConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import MediaInfo from './MediaInfo';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeFileSelector(),
+ (episodeFile) => {
+ if (episodeFile) {
+ return {
+ ...episodeFile.mediaInfo
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MediaInfo);
diff --git a/frontend/src/EpisodeFile/mediaInfoTypes.js b/frontend/src/EpisodeFile/mediaInfoTypes.js
new file mode 100644
index 000000000..5e5a78e64
--- /dev/null
+++ b/frontend/src/EpisodeFile/mediaInfoTypes.js
@@ -0,0 +1,2 @@
+export const AUDIO = 'audio';
+export const VIDEO = 'video';
diff --git a/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js
new file mode 100644
index 000000000..11cca7d1b
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js
@@ -0,0 +1,11 @@
+import PropTypes from 'prop-types';
+
+function createRouteMatchShape(props) {
+ return PropTypes.shape({
+ params: PropTypes.shape({
+ ...props
+ }).isRequired
+ });
+}
+
+export default createRouteMatchShape;
diff --git a/frontend/src/Helpers/Props/Shapes/locationShape.js b/frontend/src/Helpers/Props/Shapes/locationShape.js
new file mode 100644
index 000000000..80b53eb44
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/locationShape.js
@@ -0,0 +1,11 @@
+import PropTypes from 'prop-types';
+
+const locationShape = PropTypes.shape({
+ pathname: PropTypes.string.isRequired,
+ search: PropTypes.string.isRequired,
+ state: PropTypes.object,
+ action: PropTypes.string,
+ key: PropTypes.string
+});
+
+export default locationShape;
diff --git a/frontend/src/Helpers/Props/Shapes/settingShape.js b/frontend/src/Helpers/Props/Shapes/settingShape.js
new file mode 100644
index 000000000..cd672de27
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/settingShape.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+
+const settingShape = {
+ value: PropTypes.oneOf([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ warnings: PropTypes.arrayOf(PropTypes.string).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export const arraySettingShape = {
+ ...settingShape,
+ value: PropTypes.array.isRequired
+};
+
+export const boolSettingShape = {
+ ...settingShape,
+ value: PropTypes.bool.isRequired
+};
+
+export const numberSettingShape = {
+ ...settingShape,
+ value: PropTypes.number.isRequired
+};
+
+export const stringSettingShape = {
+ ...settingShape,
+ value: PropTypes.string
+};
+
+export const tagSettingShape = {
+ ...settingShape,
+ value: PropTypes.arrayOf(PropTypes.number).isRequired
+};
+
+export default settingShape;
diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.js
new file mode 100644
index 000000000..f381959c6
--- /dev/null
+++ b/frontend/src/Helpers/Props/align.js
@@ -0,0 +1,5 @@
+export const LEFT = 'left';
+export const CENTER = 'center';
+export const RIGHT = 'right';
+
+export const all = [LEFT, CENTER, RIGHT];
diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js
new file mode 100644
index 000000000..72722ab63
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterBuilderTypes.js
@@ -0,0 +1,50 @@
+import * as filterTypes from './filterTypes';
+
+export const ARRAY = 'array';
+export const DATE = 'date';
+export const EXACT = 'exact';
+export const NUMBER = 'number';
+export const STRING = 'string';
+
+export const all = [
+ ARRAY,
+ DATE,
+ EXACT,
+ NUMBER,
+ STRING
+];
+
+export const possibleFilterTypes = {
+ [ARRAY]: [
+ { key: filterTypes.CONTAINS, value: 'contains' },
+ { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }
+ ],
+
+ [DATE]: [
+ { key: filterTypes.LESS_THAN, value: 'is before' },
+ { key: filterTypes.GREATER_THAN, value: 'is after' },
+ { key: filterTypes.IN_LAST, value: 'in the last' },
+ { key: filterTypes.IN_NEXT, value: 'in the next' }
+ ],
+
+ [EXACT]: [
+ { key: filterTypes.EQUAL, value: 'is' },
+ { key: filterTypes.NOT_EQUAL, value: 'is not' }
+ ],
+
+ [NUMBER]: [
+ { key: filterTypes.EQUAL, value: 'equal' },
+ { key: filterTypes.GREATER_THAN, value: 'greater than' },
+ { key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' },
+ { key: filterTypes.LESS_THAN, value: 'less than' },
+ { key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' },
+ { key: filterTypes.NOT_EQUAL, value: 'not equal' }
+ ],
+
+ [STRING]: [
+ { key: filterTypes.CONTAINS, value: 'contains' },
+ { key: filterTypes.NOT_CONTAINS, value: 'does not contain' },
+ { key: filterTypes.EQUAL, value: 'equal' },
+ { key: filterTypes.NOT_EQUAL, value: 'not equal' }
+ ]
+};
diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
new file mode 100644
index 000000000..cacc24fda
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
@@ -0,0 +1,11 @@
+export const BOOL = 'bool';
+export const BYTES = 'bytes';
+export const DATE = 'date';
+export const DEFAULT = 'default';
+export const INDEXER = 'indexer';
+export const LANGUAGE_PROFILE = 'languageProfile';
+export const PROTOCOL = 'protocol';
+export const QUALITY = 'quality';
+export const QUALITY_PROFILE = 'qualityProfile';
+export const SERIES_STATUS = 'seriesStatus';
+export const TAG = 'tag';
diff --git a/frontend/src/Helpers/Props/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js
new file mode 100644
index 000000000..a3ea11956
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterTypePredicates.js
@@ -0,0 +1,45 @@
+import * as filterTypes from './filterTypes';
+
+const filterTypePredicates = {
+ [filterTypes.CONTAINS]: function(itemValue, filterValue) {
+ if (Array.isArray(itemValue)) {
+ return itemValue.some((v) => v === filterValue);
+ }
+
+ return itemValue.toLowerCase().contains(filterValue.toLowerCase());
+ },
+
+ [filterTypes.EQUAL]: function(itemValue, filterValue) {
+ return itemValue === filterValue;
+ },
+
+ [filterTypes.GREATER_THAN]: function(itemValue, filterValue) {
+ return itemValue > filterValue;
+ },
+
+ [filterTypes.GREATER_THAN_OR_EQUAL]: function(itemValue, filterValue) {
+ return itemValue >= filterValue;
+ },
+
+ [filterTypes.LESS_THAN]: function(itemValue, filterValue) {
+ return itemValue < filterValue;
+ },
+
+ [filterTypes.LESS_THAN_OR_EQUAL]: function(itemValue, filterValue) {
+ return itemValue <= filterValue;
+ },
+
+ [filterTypes.NOT_CONTAINS]: function(itemValue, filterValue) {
+ if (Array.isArray(itemValue)) {
+ return !itemValue.some((v) => v === filterValue);
+ }
+
+ return !itemValue.toLowerCase().contains(filterValue.toLowerCase());
+ },
+
+ [filterTypes.NOT_EQUAL]: function(itemValue, filterValue) {
+ return itemValue !== filterValue;
+ }
+};
+
+export default filterTypePredicates;
diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js
new file mode 100644
index 000000000..77809b8ce
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterTypes.js
@@ -0,0 +1,21 @@
+export const CONTAINS = 'contains';
+export const EQUAL = 'equal';
+export const GREATER_THAN = 'greaterThan';
+export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual';
+export const IN_LAST = 'inLast';
+export const IN_NEXT = 'inNext';
+export const LESS_THAN = 'lessThan';
+export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual';
+export const NOT_CONTAINS = 'notContains';
+export const NOT_EQUAL = 'notEqual';
+
+export const all = [
+ CONTAINS,
+ EQUAL,
+ GREATER_THAN,
+ GREATER_THAN_OR_EQUAL,
+ LESS_THAN,
+ LESS_THAN_OR_EQUAL,
+ NOT_CONTAINS,
+ NOT_EQUAL
+];
diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js
new file mode 100644
index 000000000..7865dcd6e
--- /dev/null
+++ b/frontend/src/Helpers/Props/icons.js
@@ -0,0 +1,201 @@
+//
+// Regular
+
+import {
+ faBookmark as farBookmark,
+ faCalendar as farCalendar,
+ faCircle as farCircle,
+ faClock as farClock,
+ faClone as farClone,
+ faDotCircle as farDotCircle,
+ faFile as farFile,
+ faFileArchive as farFileArchive,
+ faFileVideo as farFileVideo,
+ faFolder as farFolder,
+ faObjectGroup as farObjectGroup,
+ faHdd as farHdd,
+ faKeyboard as farKeyboard,
+ faObjectUngroup as farObjectUngroup
+} from '@fortawesome/free-regular-svg-icons';
+
+//
+// Solid
+
+import {
+ faArrowCircleLeft as fasArrowCircleLeft,
+ faArrowCircleRight as fasArrowCircleRight,
+ faBackward as fasBackward,
+ faBars as fasBars,
+ faBolt as fasBolt,
+ faBookmark as fasBookmark,
+ faBookReader as fasBookReader,
+ faBug as fasBug,
+ faBroadcastTower as fasBroadcastTower,
+ faCalendarAlt as fasCalendarAlt,
+ faCaretDown as fasCaretDown,
+ faCheck as fasCheck,
+ faChevronCircleDown as fasChevronCircleDown,
+ faChevronCircleRight as fasChevronCircleRight,
+ faChevronCircleUp as fasChevronCircleUp,
+ faCheckCircle as fasCheckCircle,
+ faCircle as fasCircle,
+ faCloudDownloadAlt as fasCloudDownloadAlt,
+ faCloud as fasCloud,
+ faCog as fasCog,
+ faCogs as fasCogs,
+ faCopy as fasCopy,
+ faDesktop as fasDesktop,
+ faDownload as fasDownload,
+ faEllipsisH as fasEllipsisH,
+ faExclamationCircle as fasExclamationCircle,
+ faExclamationTriangle as fasExclamationTriangle,
+ faExternalLinkAlt as fasExternalLinkAlt,
+ faEye as fasEye,
+ faFastBackward as fasFastBackward,
+ faFastForward as fasFastForward,
+ faFileInvoice as farFileInvoice,
+ faFilter as fasFilter,
+ faFolderOpen as fasFolderOpen,
+ faForward as fasForward,
+ faHeart as fasHeart,
+ faHistory as fasHistory,
+ faHome as fasHome,
+ faInfoCircle as fasInfoCircle,
+ faLaptop as fasLaptop,
+ faLevelUpAlt as fasLevelUpAlt,
+ faMedkit as fasMedkit,
+ faMinus as fasMinus,
+ faPause as fasPause,
+ faPlay as fasPlay,
+ faPlus as fasPlus,
+ faPowerOff as fasPowerOff,
+ faQuestion as fasQuestion,
+ faQuestionCircle as fasQuestionCircle,
+ faRedoAlt as fasRedoAlt,
+ faRetweet as fasRetweet,
+ faRss as fasRss,
+ faRocket as fasRocket,
+ faSave as fasSave,
+ faSearch as fasSearch,
+ faSignOutAlt as fasSignOutAlt,
+ faSitemap as fasSitemap,
+ faSpinner as fasSpinner,
+ faSort as fasSort,
+ faSortDown as fasSortDown,
+ faSortUp as fasSortUp,
+ faStop as fasStop,
+ faSync as fasSync,
+ faTags as fasTags,
+ faTh as fasTh,
+ faThList as fasThList,
+ faTrashAlt as fasTrashAlt,
+ faTimes as fasTimes,
+ faTimesCircle as fasTimesCircle,
+ faUser as fasUser,
+ faUserPlus as fasUserPlus,
+ faVial as fasVial,
+ faWrench as fasWrench
+} from '@fortawesome/free-solid-svg-icons';
+
+//
+// Icons
+
+export const ACTIONS = fasBolt;
+export const ACTIVITY = farClock;
+export const ADD = fasPlus;
+export const ALTERNATE_TITLES = farClone;
+export const ADVANCED_SETTINGS = fasCog;
+export const ARROW_LEFT = fasArrowCircleLeft;
+export const ARROW_RIGHT = fasArrowCircleRight;
+export const BACKUP = farFileArchive;
+export const BUG = fasBug;
+export const CALENDAR = fasCalendarAlt;
+export const CALENDAR_O = farCalendar;
+export const CARET_DOWN = fasCaretDown;
+export const CHECK = fasCheck;
+export const CHECK_INDETERMINATE = fasMinus;
+export const CHECK_CIRCLE = fasCheckCircle;
+export const CIRCLE = fasCircle;
+export const CIRCLE_OUTLINE = farCircle;
+export const CLEAR = fasTrashAlt;
+export const CLIPBOARD = fasCopy;
+export const CLOSE = fasTimes;
+export const CLONE = farClone;
+export const COLLAPSE = fasChevronCircleUp;
+export const COMPUTER = fasDesktop;
+export const DANGER = fasExclamationCircle;
+export const DELETE = fasTrashAlt;
+export const DOWNLOAD = fasDownload;
+export const DOWNLOADED = fasDownload;
+export const DOWNLOADING = fasCloudDownloadAlt;
+export const DRIVE = farHdd;
+export const EDIT = fasWrench;
+export const EPISODE_FILE = farFileVideo;
+export const EXPAND = fasChevronCircleDown;
+export const EXPAND_INDETERMINATE = fasChevronCircleRight;
+export const EXTERNAL_LINK = fasExternalLinkAlt;
+export const FATAL = fasTimesCircle;
+export const FILE = farFile;
+export const FILTER = fasFilter;
+export const FOLDER = farFolder;
+export const FOLDER_OPEN = fasFolderOpen;
+export const GROUP = farObjectGroup;
+export const HEALTH = fasMedkit;
+export const HEART = fasHeart;
+export const HISTORY = fasHistory;
+export const HOUSEKEEPING = fasHome;
+export const INFO = fasInfoCircle;
+export const INTERACTIVE = fasUser;
+export const KEYBOARD = farKeyboard;
+export const LOGOUT = fasSignOutAlt;
+export const MEDIA_INFO = farFileInvoice;
+export const MISSING = fasExclamationTriangle;
+export const MONITORED = fasBookmark;
+export const NETWORK = fasBroadcastTower;
+export const NAVBAR_COLLAPSE = fasBars;
+export const NOT_AIRED = farClock;
+export const ORGANIZE = fasSitemap;
+export const OVERFLOW = fasEllipsisH;
+export const OVERVIEW = fasThList;
+export const PAGE_FIRST = fasFastBackward;
+export const PAGE_PREVIOUS = fasBackward;
+export const PAGE_NEXT = fasForward;
+export const PAGE_LAST = fasFastForward;
+export const PARENT = fasLevelUpAlt;
+export const PAUSED = fasPause;
+export const PENDING = farClock;
+export const PROFILE = fasUser;
+export const POSTER = fasTh;
+export const QUEUED = fasCloud;
+export const QUICK = fasRocket;
+export const REFRESH = fasSync;
+export const REMOVE = fasTimes;
+export const RESTART = fasRedoAlt;
+export const RESTORE = fasHistory;
+export const REORDER = fasBars;
+export const RSS = fasRss;
+export const SAVE = fasSave;
+export const SCHEDULED = farClock;
+export const SCORE = fasUserPlus;
+export const SEARCH = fasSearch;
+export const SERIES_CONTINUING = fasPlay;
+export const SERIES_ENDED = fasStop;
+export const SETTINGS = fasCogs;
+export const SHUTDOWN = fasPowerOff;
+export const SORT = fasSort;
+export const SORT_ASCENDING = fasSortUp;
+export const SORT_DESCENDING = fasSortDown;
+export const SPINNER = fasSpinner;
+export const SUBTRACT = fasMinus;
+export const SYSTEM = fasLaptop;
+export const TAGS = fasTags;
+export const TBA = fasQuestionCircle;
+export const TEST = fasVial;
+export const UNGROUP = farObjectUngroup;
+export const UNKNOWN = fasQuestion;
+export const UNMONITORED = farBookmark;
+export const UPDATE = fasRetweet;
+export const UNSAVED_SETTING = farDotCircle;
+export const VIEW = fasEye;
+export const WARNING = fasExclamationTriangle;
+export const WIKI = fasBookReader;
diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js
new file mode 100644
index 000000000..3f4f94f6f
--- /dev/null
+++ b/frontend/src/Helpers/Props/index.js
@@ -0,0 +1,29 @@
+import * as align from './align';
+import * as inputTypes from './inputTypes';
+import * as filterBuilderTypes from './filterBuilderTypes';
+import * as filterBuilderValueTypes from './filterBuilderValueTypes';
+import filterTypePredicates from './filterTypePredicates';
+import * as filterTypes from './filterTypes';
+import * as icons from './icons';
+import * as kinds from './kinds';
+import * as messageTypes from './messageTypes';
+import * as sizes from './sizes';
+import * as scrollDirections from './scrollDirections';
+import * as sortDirections from './sortDirections';
+import * as tooltipPositions from './tooltipPositions';
+
+export {
+ align,
+ inputTypes,
+ filterBuilderTypes,
+ filterBuilderValueTypes,
+ filterTypePredicates,
+ filterTypes,
+ icons,
+ kinds,
+ messageTypes,
+ sizes,
+ scrollDirections,
+ sortDirections,
+ tooltipPositions
+};
diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js
new file mode 100644
index 000000000..78a8aa1af
--- /dev/null
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -0,0 +1,39 @@
+export const AUTO_COMPLETE = 'autoComplete';
+export const CAPTCHA = 'captcha';
+export const CHECK = 'check';
+export const DEVICE = 'device';
+export const KEY_VALUE_LIST = 'keyValueList';
+export const MONITOR_EPISODES_SELECT = 'monitorEpisodesSelect';
+export const NUMBER = 'number';
+export const OAUTH = 'oauth';
+export const PASSWORD = 'password';
+export const PATH = 'path';
+export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
+export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect';
+export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
+export const SELECT = 'select';
+export const SERIES_TYPE_SELECT = 'seriesTypeSelect';
+export const TAG = 'tag';
+export const TEXT = 'text';
+export const TEXT_TAG = 'textTag';
+
+export const all = [
+ AUTO_COMPLETE,
+ CAPTCHA,
+ CHECK,
+ DEVICE,
+ KEY_VALUE_LIST,
+ MONITOR_EPISODES_SELECT,
+ NUMBER,
+ OAUTH,
+ PASSWORD,
+ PATH,
+ QUALITY_PROFILE_SELECT,
+ LANGUAGE_PROFILE_SELECT,
+ ROOT_FOLDER_SELECT,
+ SELECT,
+ SERIES_TYPE_SELECT,
+ TAG,
+ TEXT,
+ TEXT_TAG
+];
diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js
new file mode 100644
index 000000000..fd2c17f7b
--- /dev/null
+++ b/frontend/src/Helpers/Props/kinds.js
@@ -0,0 +1,23 @@
+export const DANGER = 'danger';
+export const DEFAULT = 'default';
+export const DISABLED = 'disabled';
+export const INFO = 'info';
+export const INVERSE = 'inverse';
+export const PINK = 'pink';
+export const PRIMARY = 'primary';
+export const PURPLE = 'purple';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ DANGER,
+ DEFAULT,
+ DISABLED,
+ INFO,
+ INVERSE,
+ PINK,
+ PRIMARY,
+ PURPLE,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js
new file mode 100644
index 000000000..997354f9d
--- /dev/null
+++ b/frontend/src/Helpers/Props/messageTypes.js
@@ -0,0 +1,11 @@
+export const ERROR = 'error';
+export const INFO = 'info';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ ERROR,
+ INFO,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js
new file mode 100644
index 000000000..5e4a4fe08
--- /dev/null
+++ b/frontend/src/Helpers/Props/scrollDirections.js
@@ -0,0 +1,5 @@
+export const NONE = 'none';
+export const HORIZONTAL = 'horizontal';
+export const VERTICAL = 'vertical';
+
+export const all = [NONE, HORIZONTAL, VERTICAL];
diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js
new file mode 100644
index 000000000..d7f85df5e
--- /dev/null
+++ b/frontend/src/Helpers/Props/sizes.js
@@ -0,0 +1,7 @@
+export const EXTRA_SMALL = 'extraSmall';
+export const SMALL = 'small';
+export const MEDIUM = 'medium';
+export const LARGE = 'large';
+export const EXTRA_LARGE = 'extraLarge';
+
+export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE];
diff --git a/frontend/src/Helpers/Props/sortDirections.js b/frontend/src/Helpers/Props/sortDirections.js
new file mode 100644
index 000000000..ff3b17bb6
--- /dev/null
+++ b/frontend/src/Helpers/Props/sortDirections.js
@@ -0,0 +1,4 @@
+export const ASCENDING = 'ascending';
+export const DESCENDING = 'descending';
+
+export const all = [ASCENDING, DESCENDING];
diff --git a/frontend/src/Helpers/Props/tooltipPositions.js b/frontend/src/Helpers/Props/tooltipPositions.js
new file mode 100644
index 000000000..bca3c4ed4
--- /dev/null
+++ b/frontend/src/Helpers/Props/tooltipPositions.js
@@ -0,0 +1,11 @@
+export const TOP = 'top';
+export const RIGHT = 'right';
+export const BOTTOM = 'bottom';
+export const LEFT = 'left';
+
+export const all = [
+ TOP,
+ RIGHT,
+ BOTTOM,
+ LEFT
+];
diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js
new file mode 100644
index 000000000..ed6ba080d
--- /dev/null
+++ b/frontend/src/Helpers/dragTypes.js
@@ -0,0 +1,3 @@
+export const QUALITY_PROFILE_ITEM = 'qualityProfileItem';
+export const DELAY_PROFILE = 'delayProfile';
+export const TABLE_COLUMN = 'tableColumn';
diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js
new file mode 100644
index 000000000..1c10b2f0e
--- /dev/null
+++ b/frontend/src/Helpers/elementChildren.js
@@ -0,0 +1,149 @@
+// https://github.com/react-bootstrap/react-element-children
+
+import React from 'react';
+
+/**
+ * Iterates through children that are typically specified as `props.children`,
+ * but only maps over children that are "valid components".
+ *
+ * The mapFunction provided index will be normalised to the components mapped,
+ * so an invalid component would not increase the index.
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for func.
+ * @return {object} Object containing the ordered map of results.
+ */
+export function map(children, func, context) {
+ let index = 0;
+
+ return React.Children.map(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return child;
+ }
+
+ return func.call(context, child, index++);
+ });
+}
+
+/**
+ * Iterates through children that are "valid components".
+ *
+ * The provided forEachFunc(child, index) will be called for each
+ * leaf child with the index reflecting the position relative to "valid components".
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for context.
+ */
+export function forEach(children, func, context) {
+ let index = 0;
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ func.call(context, child, index++);
+ });
+}
+
+/**
+ * Count the number of "valid components" in the Children container.
+ *
+ * @param {?*} children Children tree container.
+ * @returns {number}
+ */
+export function count(children) {
+ let result = 0;
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ ++result;
+ });
+
+ return result;
+}
+
+/**
+ * Finds children that are typically specified as `props.children`,
+ * but only iterates over children that are "valid components".
+ *
+ * The provided forEachFunc(child, index) will be called for each
+ * leaf child with the index reflecting the position relative to "valid components".
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for func.
+ * @returns {array} of children that meet the func return statement
+ */
+export function filter(children, func, context) {
+ const result = [];
+
+ forEach(children, (child, index) => {
+ if (func.call(context, child, index)) {
+ result.push(child);
+ }
+ });
+
+ return result;
+}
+
+export function find(children, func, context) {
+ let result = null;
+
+ forEach(children, (child, index) => {
+ if (result) {
+ return;
+ }
+ if (func.call(context, child, index)) {
+ result = child;
+ }
+ });
+
+ return result;
+}
+
+export function every(children, func, context) {
+ let result = true;
+
+ forEach(children, (child, index) => {
+ if (!result) {
+ return;
+ }
+ if (!func.call(context, child, index)) {
+ result = false;
+ }
+ });
+
+ return result;
+}
+
+export function some(children, func, context) {
+ let result = false;
+
+ forEach(children, (child, index) => {
+ if (result) {
+ return;
+ }
+
+ if (func.call(context, child, index)) {
+ result = true;
+ }
+ });
+
+ return result;
+}
+
+export function toArray(children) {
+ const result = [];
+
+ forEach(children, (child) => {
+ result.push(child);
+ });
+
+ return result;
+}
diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js
new file mode 100644
index 000000000..512702c87
--- /dev/null
+++ b/frontend/src/Helpers/getDisplayName.js
@@ -0,0 +1,3 @@
+export default function getDisplayName(Component) {
+ return Component.displayName || Component.name || 'Component';
+}
diff --git a/frontend/src/Hotkeys/Hotkeys.js b/frontend/src/Hotkeys/Hotkeys.js
new file mode 100644
index 000000000..9aa64914b
--- /dev/null
+++ b/frontend/src/Hotkeys/Hotkeys.js
@@ -0,0 +1,34 @@
+var $ = require('jquery');
+var vent = require('vent');
+var HotkeysView = require('./HotkeysView');
+
+$(document).on('keypress', function(e) {
+ if ($(e.target).is('input') || $(e.target).is('textarea')) {
+ return;
+ }
+
+ if (e.charCode === 63) {
+ vent.trigger(vent.Commands.OpenFullscreenModal, new HotkeysView());
+ }
+});
+
+$(document).on('keydown', function(e) {
+ if (e.ctrlKey && e.keyCode === 83) {
+ vent.trigger(vent.Hotkeys.SaveSettings);
+ e.preventDefault();
+ return;
+ }
+
+ if ($(e.target).is('input') || $(e.target).is('textarea')) {
+ return;
+ }
+
+ if (e.ctrlKey || e.metaKey || e.altKey) {
+ return;
+ }
+
+ if (e.keyCode === 84) {
+ vent.trigger(vent.Hotkeys.NavbarSearch);
+ e.preventDefault();
+ }
+});
diff --git a/frontend/src/Hotkeys/HotkeysView.js b/frontend/src/Hotkeys/HotkeysView.js
new file mode 100644
index 000000000..67575c726
--- /dev/null
+++ b/frontend/src/Hotkeys/HotkeysView.js
@@ -0,0 +1,6 @@
+var vent = require('vent');
+var Marionette = require('marionette');
+
+module.exports = Marionette.ItemView.extend({
+ template: 'Hotkeys/HotkeysViewTemplate'
+});
diff --git a/frontend/src/Hotkeys/HotkeysViewTemplate.hbs b/frontend/src/Hotkeys/HotkeysViewTemplate.hbs
new file mode 100644
index 000000000..b0b181603
--- /dev/null
+++ b/frontend/src/Hotkeys/HotkeysViewTemplate.hbs
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
Focus Search Box
+
+
+ t
+
+
+
+
+ Pressing 't' puts the cursor in the search box below the navigation links
+
+
+
+
+
+
+
+
+
Save Settings
+
+
+ ctrl + s
+
+
+
+
+ Pressing ctrl + 's' saves your settings (only in settings)
+
+
+
+
+
+
+
diff --git a/frontend/src/Hotkeys/hotkeys.less b/frontend/src/Hotkeys/hotkeys.less
new file mode 100644
index 000000000..3e06b5c18
--- /dev/null
+++ b/frontend/src/Hotkeys/hotkeys.less
@@ -0,0 +1,23 @@
+.hotkeys-modal {
+ h3 {
+ margin-top: 0px;
+ margin-botton: 0px;
+ }
+
+ .hotkey-group {
+ &:first-of-type {
+ margin-top: 0px;
+ }
+
+ &:last-of-type {
+ margin-bottom: 0px;
+ }
+
+ margin-top: 25px;
+ margin-bottom: 25px;
+
+ .hotkey {
+ font-size: 22px;
+ }
+ }
+}
diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js
new file mode 100644
index 000000000..31ea74d23
--- /dev/null
+++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectEpisodeModalContentConnector from './SelectEpisodeModalContentConnector';
+
+class SelectEpisodeModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectEpisodeModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectEpisodeModal;
diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js
new file mode 100644
index 000000000..8f8906839
--- /dev/null
+++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js
@@ -0,0 +1,184 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 SelectEpisodeRow from './SelectEpisodeRow';
+
+const columns = [
+ {
+ name: 'episodeNumber',
+ label: '#',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isVisible: true
+ },
+ {
+ name: 'airDate',
+ label: 'Air Date',
+ isVisible: true
+ }
+];
+
+class SelectEpisodeModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {}
+ };
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onEpisodesSelect = () => {
+ this.props.onEpisodesSelect(this.getSelectedIds());
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ onSortPress,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const errorMessage = getErrorMessage(error, 'Unable to load episodes');
+
+ return (
+
+
+ Manual Import - Select Episode(s)
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+ {
+ isPopulated && !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length &&
+ 'No episodes were found for the selected season'
+ }
+
+
+
+
+ Cancel
+
+
+
+ Select Episodes
+
+
+
+ );
+ }
+}
+
+SelectEpisodeModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ onSortPress: PropTypes.func.isRequired,
+ onEpisodesSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectEpisodeModalContent;
diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js
new file mode 100644
index 000000000..016cc7a66
--- /dev/null
+++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js
@@ -0,0 +1,101 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import {
+ updateInteractiveImportItem,
+ fetchInteractiveImportEpisodes,
+ setInteractiveImportEpisodesSort,
+ clearInteractiveImportEpisodes
+} from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import SelectEpisodeModalContent from './SelectEpisodeModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('interactiveImport.episodes'),
+ (episodes) => {
+ return episodes;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportEpisodes,
+ setInteractiveImportEpisodesSort,
+ clearInteractiveImportEpisodes,
+ updateInteractiveImportItem
+};
+
+class SelectEpisodeModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.fetchInteractiveImportEpisodes({ seriesId, seasonNumber });
+ }
+
+ componentWillUnmount() {
+ // This clears the episodes for the queue and hides the queue
+ // We'll need another place to store episodes for manual import
+ this.props.clearInteractiveImportEpisodes();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportEpisodesSort({ sortKey, sortDirection });
+ }
+
+ onEpisodesSelect = (episodeIds) => {
+ const episodes = _.reduce(this.props.items, (acc, item) => {
+ if (episodeIds.indexOf(item.id) > -1) {
+ acc.push(item);
+ }
+
+ return acc;
+ }, []);
+
+ this.props.updateInteractiveImportItem({
+ id: this.props.id,
+ episodes: _.sortBy(episodes, 'episodeNumber')
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectEpisodeModalContentConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchInteractiveImportEpisodes: PropTypes.func.isRequired,
+ setInteractiveImportEpisodesSort: PropTypes.func.isRequired,
+ clearInteractiveImportEpisodes: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectEpisodeModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js
new file mode 100644
index 000000000..ba455121a
--- /dev/null
+++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+
+class SelectEpisodeRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ id,
+ isSelected
+ } = this.props;
+
+ this.props.onSelectedChange({ id, value: !isSelected });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ episodeNumber,
+ title,
+ airDate,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ return (
+
+
+
+
+ {episodeNumber}
+
+
+
+ {title}
+
+
+
+ {airDate}
+
+
+ );
+ }
+}
+
+SelectEpisodeRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ airDate: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default SelectEpisodeRow;
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css
new file mode 100644
index 000000000..86418a2dd
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css
@@ -0,0 +1,24 @@
+.recentFoldersContainer {
+ margin-top: 15px;
+}
+
+.buttonsContainer {
+ margin-top: 30px;
+}
+
+.buttonContainer {
+ display: flex;
+ justify-content: center;
+
+ margin-top: 10px;
+}
+
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ width: 300px;
+}
+
+.buttonIcon {
+ margin-right: 5px;
+}
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js
new file mode 100644
index 000000000..78df1f53e
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js
@@ -0,0 +1,168 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import PathInputConnector from 'Components/Form/PathInputConnector';
+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 RecentFolderRow from './RecentFolderRow';
+import styles from './InteractiveImportSelectFolderModalContent.css';
+
+const recentFoldersColumns = [
+ {
+ name: 'folder',
+ label: 'Folder'
+ },
+ {
+ name: 'lastUsed',
+ label: 'Last Used'
+ },
+ {
+ name: 'actions',
+ label: ''
+ }
+];
+
+class InteractiveImportSelectFolderModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ folder: ''
+ };
+ }
+
+ //
+ // Listeners
+
+ onPathChange = ({ value }) => {
+ this.setState({ folder: value });
+ }
+
+ onRecentPathPress = (folder) => {
+ this.setState({ folder });
+ }
+
+ onQuickImportPress = () => {
+ this.props.onQuickImportPress(this.state.folder);
+ }
+
+ onInteractiveImportPress = () => {
+ this.props.onInteractiveImportPress(this.state.folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ recentFolders,
+ onRemoveRecentFolderPress,
+ onModalClose
+ } = this.props;
+
+ const folder = this.state.folder;
+
+ return (
+
+
+ Manual Import - Select Folder
+
+
+
+
+
+ {
+ !!recentFolders.length &&
+
+
+
+ {
+ recentFolders.map((recentFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+
+
+
+ Quick Import
+
+
+
+
+
+
+
+ Interactive Import
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+InteractiveImportSelectFolderModalContent.propTypes = {
+ recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onQuickImportPress: PropTypes.func.isRequired,
+ onInteractiveImportPress: PropTypes.func.isRequired,
+ onRemoveRecentFolderPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default InteractiveImportSelectFolderModalContent;
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js
new file mode 100644
index 000000000..92d729800
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.interactiveImport.recentFolders,
+ (recentFolders) => {
+ return {
+ recentFolders
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ addRecentFolder,
+ removeRecentFolder,
+ executeCommand
+};
+
+class InteractiveImportSelectFolderModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onQuickImportPress = (folder) => {
+ this.props.addRecentFolder({ folder });
+
+ this.props.executeCommand({
+ name: commandNames.DOWNLOADED_EPSIODES_SCAN,
+ path: folder
+ });
+
+ this.props.onModalClose();
+ }
+
+ onInteractiveImportPress = (folder) => {
+ this.props.addRecentFolder({ folder });
+ this.props.onFolderSelect(folder);
+ }
+
+ onRemoveRecentFolderPress = (folder) => {
+ this.props.removeRecentFolder({ folder });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (this.path) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+InteractiveImportSelectFolderModalContentConnector.propTypes = {
+ path: PropTypes.string,
+ onFolderSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ addRecentFolder: PropTypes.func.isRequired,
+ removeRecentFolder: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.css b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css
new file mode 100644
index 000000000..edb55b075
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css
@@ -0,0 +1,5 @@
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 40px;
+}
diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
new file mode 100644
index 000000000..403bce33d
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import styles from './RecentFolderRow.css';
+
+class RecentFolderRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.folder);
+ }
+
+ onRemovePress = (event) => {
+ event.stopPropagation();
+
+ const {
+ folder,
+ onRemoveRecentFolderPress
+ } = this.props;
+
+ onRemoveRecentFolderPress(folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ folder,
+ lastUsed
+ } = this.props;
+
+ return (
+
+ {folder}
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RecentFolderRow.propTypes = {
+ folder: PropTypes.string.isRequired,
+ lastUsed: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired,
+ onRemoveRecentFolderPress: PropTypes.func.isRequired
+};
+
+export default RecentFolderRow;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
new file mode 100644
index 000000000..5bad6c050
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
@@ -0,0 +1,73 @@
+.filterContainer {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 10px;
+}
+
+.filterText {
+ margin-left: 5px;
+}
+
+.footer {
+ composes: modalFooter from 'Components/Modal/ModalFooter.css';
+
+ justify-content: space-between;
+ padding: 15px;
+}
+
+.leftButtons,
+.centerButtons,
+.rightButtons {
+ display: flex;
+ flex: 1 0 33%;
+ flex-wrap: wrap;
+}
+
+.centerButtons {
+ justify-content: center;
+}
+
+.rightButtons {
+ justify-content: flex-end;
+}
+
+.importMode {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ width: auto;
+}
+
+.errorMessage {
+ color: $dangerColor;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .footer {
+ .leftButtons,
+ .centerButtons,
+ .rightButtons {
+ flex-direction: column;
+ }
+
+ .leftButtons {
+ align-items: flex-start;
+ }
+
+ .centerButtons {
+ align-items: center;
+ }
+
+ .rightButtons {
+ align-items: flex-end;
+ }
+
+ a,
+ button {
+ margin-left: 0;
+
+ &:first-child {
+ margin-bottom: 5px;
+ }
+ }
+ }
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js
new file mode 100644
index 000000000..f0eaf0965
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js
@@ -0,0 +1,402 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectInput from 'Components/Form/SelectInput';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
+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 SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
+import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
+import InteractiveImportRow from './InteractiveImportRow';
+import styles from './InteractiveImportModalContent.css';
+
+const columns = [
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'series',
+ label: 'Series',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'season',
+ label: 'Season',
+ isVisible: true
+ },
+ {
+ name: 'episodes',
+ label: 'Episode(s)',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, {
+ name: icons.DANGER,
+ kind: kinds.DANGER
+ }),
+ isVisible: true
+ }
+];
+
+const filterExistingFilesOptions = {
+ ALL: 'all',
+ NEW: 'new'
+};
+
+class InteractiveImportModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ invalidRowsSelected: [],
+ isSelectSeriesModalOpen: false,
+ isSelectSeasonModalOpen: false
+ };
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onValidRowChange = (id, isValid) => {
+ this.setState((state) => {
+ if (isValid) {
+ return {
+ invalidRowsSelected: _.without(state.invalidRowsSelected, id)
+ };
+ }
+
+ return {
+ invalidRowsSelected: [...state.invalidRowsSelected, id]
+ };
+ });
+ }
+
+ onImportSelectedPress = () => {
+ const {
+ downloadId,
+ showImportMode,
+ importMode,
+ onImportSelectedPress
+ } = this.props;
+
+ const selected = this.getSelectedIds();
+ const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode;
+
+ onImportSelectedPress(selected, finalImportMode);
+ }
+
+ onFilterExistingFilesChange = (value) => {
+ this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL);
+ }
+
+ onImportModeChange = ({ value }) => {
+ this.props.onImportModeChange(value);
+ }
+
+ onSelectSeriesPress = () => {
+ this.setState({ isSelectSeriesModalOpen: true });
+ }
+
+ onSelectSeasonPress = () => {
+ this.setState({ isSelectSeasonModalOpen: true });
+ }
+
+ onSelectSeriesModalClose = () => {
+ this.setState({ isSelectSeriesModalOpen: false });
+ }
+
+ onSelectSeasonModalClose = () => {
+ this.setState({ isSelectSeasonModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ downloadId,
+ allowSeriesChange,
+ showFilterExistingFiles,
+ showImportMode,
+ filterExistingFiles,
+ title,
+ folder,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ importMode,
+ interactiveImportErrorMessage,
+ onSortPress,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ invalidRowsSelected,
+ isSelectSeriesModalOpen,
+ isSelectSeasonModalOpen
+ } = this.state;
+
+ const selectedIds = this.getSelectedIds();
+ const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null;
+ const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
+
+ const importModeOptions = [
+ { key: 'move', value: 'Move Files' },
+ { key: 'copy', value: 'Copy Files' }
+ ];
+
+ return (
+
+
+ Manual Import - {title || folder}
+
+
+
+ {
+ showFilterExistingFiles &&
+
+
+
+
+
+
+ {
+ filterExistingFiles ? 'Unmapped Files Only' : 'All Files'
+ }
+
+
+
+
+
+ All Files
+
+
+
+ Unmapped Files Only
+
+
+
+
+ }
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+ {
+ isPopulated && !!items.length && !isFetching && !isFetching &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length && !isFetching &&
+ 'No video files were found in the selected folder'
+ }
+
+
+
+
+ {
+ !downloadId && showImportMode &&
+
+ }
+
+
+
+ {
+ allowSeriesChange &&
+
+ Select Series
+
+ }
+
+
+ Select Season
+
+
+
+
+
+ Cancel
+
+
+ {
+ interactiveImportErrorMessage &&
+ {interactiveImportErrorMessage}
+ }
+
+
+ Import
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+InteractiveImportModalContent.propTypes = {
+ downloadId: PropTypes.string,
+ allowSeriesChange: PropTypes.bool.isRequired,
+ showImportMode: PropTypes.bool.isRequired,
+ showFilterExistingFiles: PropTypes.bool.isRequired,
+ filterExistingFiles: PropTypes.bool.isRequired,
+ importMode: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ folder: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ interactiveImportErrorMessage: PropTypes.string,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterExistingFilesChange: PropTypes.func.isRequired,
+ onImportModeChange: PropTypes.func.isRequired,
+ onImportSelectedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContent.defaultProps = {
+ allowSeriesChange: true,
+ showFilterExistingFiles: false,
+ showImportMode: true,
+ importMode: 'move'
+};
+
+export default InteractiveImportModalContent;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
new file mode 100644
index 000000000..3a7b03fb6
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
@@ -0,0 +1,203 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import InteractiveImportModalContent from './InteractiveImportModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('interactiveImport'),
+ (interactiveImport) => {
+ return interactiveImport;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportItems,
+ setInteractiveImportSort,
+ setInteractiveImportMode,
+ clearInteractiveImport,
+ executeCommand
+};
+
+class InteractiveImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ interactiveImportErrorMessage: null,
+ filterExistingFiles: true
+ };
+ }
+
+ componentDidMount() {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ const {
+ filterExistingFiles
+ } = this.state;
+
+ this.props.fetchInteractiveImportItems({
+ downloadId,
+ folder,
+ filterExistingFiles
+ });
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ filterExistingFiles
+ } = this.state;
+
+ if (prevState.filterExistingFiles !== filterExistingFiles) {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ this.props.fetchInteractiveImportItems({
+ downloadId,
+ folder,
+ filterExistingFiles
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearInteractiveImport();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportSort({ sortKey, sortDirection });
+ }
+
+ onFilterExistingFilesChange = (filterExistingFiles) => {
+ this.setState({ filterExistingFiles });
+ }
+
+ onImportModeChange = (importMode) => {
+ this.props.setInteractiveImportMode({ importMode });
+ }
+
+ onImportSelectedPress = (selected, importMode) => {
+ const files = [];
+
+ _.forEach(this.props.items, (item) => {
+ const isSelected = selected.indexOf(item.id) > -1;
+
+ if (isSelected) {
+ const {
+ series,
+ seasonNumber,
+ episodes,
+ quality,
+ language
+ } = item;
+
+ if (!series) {
+ this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' });
+ return false;
+ }
+
+ if (isNaN(seasonNumber)) {
+ this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!episodes || !episodes.length) {
+ this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!quality) {
+ this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!language) {
+ this.setState({ interactiveImportErrorMessage: 'Language must be chosen for each selected file' });
+ return false;
+ }
+
+ files.push({
+ path: item.path,
+ folderName: item.folderName,
+ seriesId: series.id,
+ episodeIds: _.map(episodes, 'id'),
+ quality,
+ language,
+ downloadId: this.props.downloadId
+ });
+ }
+ });
+
+ if (!files.length) {
+ return;
+ }
+
+ this.props.executeCommand({
+ name: commandNames.INTERACTIVE_IMPORT,
+ files,
+ importMode
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ interactiveImportErrorMessage,
+ filterExistingFiles
+ } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+InteractiveImportModalContentConnector.propTypes = {
+ downloadId: PropTypes.string,
+ folder: PropTypes.string,
+ filterExistingFiles: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchInteractiveImportItems: PropTypes.func.isRequired,
+ setInteractiveImportSort: PropTypes.func.isRequired,
+ clearInteractiveImport: PropTypes.func.isRequired,
+ setInteractiveImportMode: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContentConnector.defaultProps = {
+ filterExistingFiles: true
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
new file mode 100644
index 000000000..89f43cebc
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
@@ -0,0 +1,18 @@
+.relativePath {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality,
+.language {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
+
+.label {
+ composes: label from 'Components/Label.css';
+
+ cursor: pointer;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
new file mode 100644
index 000000000..d95af5d72
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
@@ -0,0 +1,370 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import Popover from 'Components/Tooltip/Popover';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
+import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
+import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
+import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
+import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
+import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
+import styles from './InteractiveImportRow.css';
+
+class InteractiveImportRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isSelectSeriesModalOpen: false,
+ isSelectSeasonModalOpen: false,
+ isSelectEpisodeModalOpen: false,
+ isSelectQualityModalOpen: false,
+ isSelectLanguageModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ const {
+ id,
+ series,
+ seasonNumber,
+ episodes,
+ quality,
+ language
+ } = this.props;
+
+ if (
+ series &&
+ seasonNumber != null &&
+ episodes.length &&
+ quality &&
+ language
+ ) {
+ this.props.onSelectedChange({ id, value: true });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ series,
+ seasonNumber,
+ episodes,
+ quality,
+ language,
+ isSelected,
+ onValidRowChange
+ } = this.props;
+
+ if (
+ prevProps.series === series &&
+ prevProps.seasonNumber === seasonNumber &&
+ !hasDifferentItems(prevProps.episodes, episodes) &&
+ prevProps.quality === quality &&
+ prevProps.language === language &&
+ prevProps.isSelected === isSelected
+ ) {
+ return;
+ }
+
+ const isValid = !!(
+ series &&
+ seasonNumber != null &&
+ episodes.length &&
+ quality &&
+ language
+ );
+
+ if (isSelected && !isValid) {
+ onValidRowChange(id, false);
+ } else {
+ onValidRowChange(id, true);
+ }
+ }
+
+ //
+ // Control
+
+ selectRowAfterChange = (value) => {
+ const {
+ id,
+ isSelected
+ } = this.props;
+
+ if (!isSelected && value === true) {
+ this.props.onSelectedChange({ id, value });
+ }
+ }
+
+ //
+ // Listeners
+
+ onSelectSeriesPress = () => {
+ this.setState({ isSelectSeriesModalOpen: true });
+ }
+
+ onSelectSeasonPress = () => {
+ this.setState({ isSelectSeasonModalOpen: true });
+ }
+
+ onSelectEpisodePress = () => {
+ this.setState({ isSelectEpisodeModalOpen: true });
+ }
+
+ onSelectQualityPress = () => {
+ this.setState({ isSelectQualityModalOpen: true });
+ }
+
+ onSelectLanguagePress = () => {
+ this.setState({ isSelectLanguageModalOpen: true });
+ }
+
+ onSelectSeriesModalClose = (changed) => {
+ this.setState({ isSelectSeriesModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectSeasonModalClose = (changed) => {
+ this.setState({ isSelectSeasonModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectEpisodeModalClose = (changed) => {
+ this.setState({ isSelectEpisodeModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectQualityModalClose = (changed) => {
+ this.setState({ isSelectQualityModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectLanguageModalClose = (changed) => {
+ this.setState({ isSelectLanguageModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ allowSeriesChange,
+ relativePath,
+ series,
+ seasonNumber,
+ episodes,
+ quality,
+ language,
+ size,
+ rejections,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ const {
+ isSelectSeriesModalOpen,
+ isSelectSeasonModalOpen,
+ isSelectEpisodeModalOpen,
+ isSelectQualityModalOpen,
+ isSelectLanguageModalOpen
+ } = this.state;
+
+ const seriesTitle = series ? series.title : '';
+ const episodeNumbers = episodes.map((episode) => episode.episodeNumber)
+ .join(', ');
+
+ const showSeriesPlaceholder = isSelected && !series;
+ const showSeasonNumberPlaceholder = isSelected && !!series && isNaN(seasonNumber);
+ const showEpisodeNumbersPlaceholder = isSelected && Number.isInteger(seasonNumber) && !episodes.length;
+ const showQualityPlaceholder = isSelected && !quality;
+ const showLanguagePlaceholder = isSelected && !language;
+
+ return (
+
+
+
+
+ {relativePath}
+
+
+
+ {
+ showSeriesPlaceholder ? : seriesTitle
+ }
+
+
+
+ {
+ showSeasonNumberPlaceholder ? : seasonNumber
+ }
+
+
+
+ {
+ showEpisodeNumbersPlaceholder ? : episodeNumbers
+ }
+
+
+
+ {
+ showQualityPlaceholder &&
+
+ }
+
+ {
+ !showQualityPlaceholder && !!quality &&
+
+ }
+
+
+
+ {
+ showLanguagePlaceholder &&
+
+ }
+
+ {
+ !showLanguagePlaceholder && !!language &&
+
+ }
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection.reason}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+
+
+
+
+
+ 1 : false}
+ real={quality ? quality.revision.real > 0 : false}
+ onModalClose={this.onSelectQualityModalClose}
+ />
+
+
+
+ );
+ }
+
+}
+
+InteractiveImportRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ allowSeriesChange: PropTypes.bool.isRequired,
+ relativePath: PropTypes.string.isRequired,
+ series: PropTypes.object,
+ seasonNumber: PropTypes.number,
+ episodes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ quality: PropTypes.object,
+ language: PropTypes.object,
+ size: PropTypes.number.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onValidRowChange: PropTypes.func.isRequired
+};
+
+InteractiveImportRow.defaultProps = {
+ episodes: []
+};
+
+export default InteractiveImportRow;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css
new file mode 100644
index 000000000..941988144
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css
@@ -0,0 +1,7 @@
+.placeholder {
+ display: inline-block;
+ margin: -8px 0;
+ width: 100%;
+ height: 25px;
+ border: 2px dashed $dangerColor;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js
new file mode 100644
index 000000000..b6744d156
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import styles from './InteractiveImportRowCellPlaceholder.css';
+
+function InteractiveImportRowCellPlaceholder() {
+ return (
+
+ );
+}
+
+export default InteractiveImportRowCellPlaceholder;
diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js
new file mode 100644
index 000000000..0ea6fd9cb
--- /dev/null
+++ b/frontend/src/InteractiveImport/InteractiveImportModal.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector';
+import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector';
+
+class InteractiveImportModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ folder: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isOpen && !this.props.isOpen) {
+ this.setState({ folder: null });
+ }
+ }
+
+ //
+ // Listeners
+
+ onFolderSelect = (folder) => {
+ this.setState({ folder });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ folder,
+ downloadId,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const folderPath = folder || this.state.folder;
+
+ return (
+
+ {
+ folderPath || downloadId ?
+ :
+
+ }
+
+ );
+ }
+}
+
+InteractiveImportModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ folder: PropTypes.string,
+ downloadId: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default InteractiveImportModal;
diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModal.js b/frontend/src/InteractiveImport/Language/SelectLanguageModal.js
new file mode 100644
index 000000000..938d26a6d
--- /dev/null
+++ b/frontend/src/InteractiveImport/Language/SelectLanguageModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector';
+
+class SelectLanguageModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectLanguageModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectLanguageModal;
diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js
new file mode 100644
index 000000000..ff99ce6bf
--- /dev/null
+++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js
@@ -0,0 +1,87 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function SelectLanguageModalContent(props) {
+ const {
+ languageId,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onModalClose,
+ onLanguageSelect
+ } = props;
+
+ const languageOptions = items.map(({ language }) => {
+ return {
+ key: language.id,
+ value: language.name
+ };
+ });
+
+ return (
+
+
+ Manual Import - Select Language
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load languages
+ }
+
+ {
+ isPopulated && !error &&
+
+ }
+
+
+
+
+ Cancel
+
+
+
+ );
+}
+
+SelectLanguageModalContent.propTypes = {
+ languageId: PropTypes.number.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onLanguageSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectLanguageModalContent;
diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js
new file mode 100644
index 000000000..56e95b861
--- /dev/null
+++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js
@@ -0,0 +1,87 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchLanguageProfileSchema } from 'Store/Actions/settingsActions';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import SelectLanguageModalContent from './SelectLanguageModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.languageProfiles,
+ (languageProfiles) => {
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = languageProfiles;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items: schema.languages ? [...schema.languages].reverse() : []
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLanguageProfileSchema,
+ updateInteractiveImportItem
+};
+
+class SelectLanguageModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.fetchLanguageProfileSchema();
+ }
+ }
+
+ //
+ // Listeners
+
+ onLanguageSelect = ({ value }) => {
+ const languageId = parseInt(value);
+ const language = _.find(this.props.items,
+ (item) => item.language.id === languageId).language;
+
+ this.props.updateInteractiveImportItem({
+ id: this.props.id,
+ language
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectLanguageModalContentConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchLanguageProfileSchema: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js
new file mode 100644
index 000000000..d3e31d2dd
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectQualityModalContentConnector from './SelectQualityModalContentConnector';
+
+class SelectQualityModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectQualityModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectQualityModal;
diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js
new file mode 100644
index 000000000..642e0433e
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+class SelectQualityModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ qualityId,
+ proper,
+ real
+ } = props;
+
+ this.state = {
+ qualityId,
+ proper,
+ real
+ };
+ }
+
+ //
+ // Listeners
+
+ onQualityChange = ({ value }) => {
+ this.setState({ qualityId: parseInt(value) });
+ }
+
+ onProperChange = ({ value }) => {
+ this.setState({ proper: value });
+ }
+
+ onRealChange = ({ value }) => {
+ this.setState({ real: value });
+ }
+
+ onQualitySelect = () => {
+ this.props.onQualitySelect(this.state);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onModalClose
+ } = this.props;
+
+ const {
+ qualityId,
+ proper,
+ real
+ } = this.state;
+
+ const qualityOptions = items.map(({ id, name }) => {
+ return {
+ key: id,
+ value: name
+ };
+ });
+
+ return (
+
+
+ Manual Import - Select Quality
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load qualities
+ }
+
+ {
+ isPopulated && !error &&
+
+ }
+
+
+
+
+ Cancel
+
+
+
+ Select Quality
+
+
+
+ );
+ }
+}
+
+SelectQualityModalContent.propTypes = {
+ qualityId: PropTypes.number.isRequired,
+ proper: PropTypes.bool.isRequired,
+ real: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onQualitySelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectQualityModalContent;
diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js
new file mode 100644
index 000000000..20a49c768
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js
@@ -0,0 +1,95 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import getQualities from 'Utilities/Quality/getQualities';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import SelectQualityModalContent from './SelectQualityModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = qualityProfiles;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items: getQualities(schema.items)
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ updateInteractiveImportItem
+};
+
+class SelectQualityModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ //
+ // Listeners
+
+ onQualitySelect = ({ qualityId, proper, real }) => {
+ const quality = _.find(this.props.items,
+ (item) => item.id === qualityId);
+
+ const revision = {
+ version: proper ? 2 : 1,
+ real: real ? 1 : 0
+ };
+
+ this.props.updateInteractiveImportItem({
+ id: this.props.id,
+ quality: {
+ quality,
+ revision
+ }
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectQualityModalContentConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModal.js b/frontend/src/InteractiveImport/Season/SelectSeasonModal.js
new file mode 100644
index 000000000..9de9ee493
--- /dev/null
+++ b/frontend/src/InteractiveImport/Season/SelectSeasonModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectSeasonModalContentConnector from './SelectSeasonModalContentConnector';
+
+class SelectSeasonModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectSeasonModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectSeasonModal;
diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js
new file mode 100644
index 000000000..267174491
--- /dev/null
+++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+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 SelectSeasonRow from './SelectSeasonRow';
+
+class SelectSeasonModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onSeasonSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Manual Import - Select Season
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+SelectSeasonModalContent.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSeasonSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectSeasonModalContent;
diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js
new file mode 100644
index 000000000..b84fdf148
--- /dev/null
+++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import SelectSeasonModalContent from './SelectSeasonModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ (series) => {
+ if (!series) {
+ return {
+ items: []
+ };
+ }
+
+ return {
+ items: series.seasons.slice(0).reverse()
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateInteractiveImportItem
+};
+
+class SelectSeasonModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onSeasonSelect = (seasonNumber) => {
+ this.props.ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ seasonNumber,
+ episodes: []
+ });
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectSeasonModalContentConnector.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.number).isRequired,
+ seriesId: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeasonModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.css b/frontend/src/InteractiveImport/Season/SelectSeasonRow.css
new file mode 100644
index 000000000..c43d879f4
--- /dev/null
+++ b/frontend/src/InteractiveImport/Season/SelectSeasonRow.css
@@ -0,0 +1,4 @@
+.season {
+ padding: 8px;
+ border-bottom: 1px solid $borderColor;
+}
diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.js b/frontend/src/InteractiveImport/Season/SelectSeasonRow.js
new file mode 100644
index 000000000..d6cf5aea1
--- /dev/null
+++ b/frontend/src/InteractiveImport/Season/SelectSeasonRow.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './SelectSeasonRow.css';
+
+class SelectSeasonRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onSeasonSelect(this.props.seasonNumber);
+ }
+
+ //
+ // Render
+
+ render() {
+ const seasonNumber = this.props.seasonNumber;
+
+ return (
+
+ {
+ seasonNumber === 0 ? 'Specials' : `Season ${seasonNumber}`
+ }
+
+ );
+ }
+}
+
+SelectSeasonRow.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ onSeasonSelect: PropTypes.func.isRequired
+};
+
+export default SelectSeasonRow;
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModal.js b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js
new file mode 100644
index 000000000..1a1ceffca
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectSeriesModalContentConnector from './SelectSeriesModalContentConnector';
+
+class SelectSeriesModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectSeriesModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectSeriesModal;
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css
new file mode 100644
index 000000000..c22d502f5
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css
@@ -0,0 +1,18 @@
+.modalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.filterInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ flex: 0 0 auto;
+ margin-bottom: 20px;
+}
+
+.scroller {
+ flex: 1 1 auto;
+}
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js
new file mode 100644
index 000000000..1b4cb52fe
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js
@@ -0,0 +1,99 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Scroller from 'Components/Scroller/Scroller';
+import TextInput from 'Components/Form/TextInput';
+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 SelectSeriesRow from './SelectSeriesRow';
+import styles from './SelectSeriesModalContent.css';
+
+class SelectSeriesModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ filter: ''
+ };
+ }
+
+ //
+ // Listeners
+
+ onFilterChange = ({ value }) => {
+ this.setState({ filter: value.toLowerCase() });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onSeriesSelect,
+ onModalClose
+ } = this.props;
+
+ const filter = this.state.filter;
+
+ return (
+
+
+ Manual Import - Select Series
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return item.title.toLowerCase().includes(filter) ?
+ (
+
+ ) :
+ null;
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+SelectSeriesModalContent.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSeriesSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectSeriesModalContent;
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js
new file mode 100644
index 000000000..07dab9f0b
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import SelectSeriesModalContent from './SelectSeriesModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createAllSeriesSelector(),
+ (items) => {
+ return {
+ items: items.sort((a, b) => {
+ if (a.sortTitle < b.sortTitle) {
+ return -1;
+ }
+
+ if (a.sortTitle > b.sortTitle) {
+ return 1;
+ }
+
+ return 0;
+ })
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateInteractiveImportItem
+};
+
+class SelectSeriesModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onSeriesSelect = (seriesId) => {
+ const series = _.find(this.props.items, { id: seriesId });
+
+ this.props.ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ series,
+ seasonNumber: undefined,
+ episodes: []
+ });
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectSeriesModalContentConnector.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.number).isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeriesModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.css b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css
new file mode 100644
index 000000000..f2573d585
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css
@@ -0,0 +1,4 @@
+.series {
+ padding: 8px;
+ border-bottom: 1px solid $borderColor;
+}
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.js b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js
new file mode 100644
index 000000000..49af64ecf
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './SelectSeriesRow.css';
+
+class SelectSeriesRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onSeriesSelect(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ {this.props.title}
+
+ );
+ }
+}
+
+SelectSeriesRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ onSeriesSelect: PropTypes.func.isRequired
+};
+
+export default SelectSeriesRow;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.css b/frontend/src/InteractiveSearch/InteractiveSearch.css
new file mode 100644
index 000000000..5e647332f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearch.css
@@ -0,0 +1,9 @@
+.filterMenuContainer {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 10px;
+}
+
+.filteredMessage {
+ margin-top: 10px;
+}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js
new file mode 100644
index 000000000..862bc0c55
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearch.js
@@ -0,0 +1,207 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Icon from 'Components/Icon';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import PageMenuButton from 'Components/Menu/PageMenuButton';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
+import InteractiveSearchRow from './InteractiveSearchRow';
+import styles from './InteractiveSearch.css';
+
+const columns = [
+ {
+ name: 'protocol',
+ label: 'Source',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'age',
+ label: 'Age',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'peers',
+ label: 'Peers',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'languageWeight',
+ label: 'Language',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityWeight',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'preferredWordScore',
+ label: React.createElement(Icon, {
+ name: icons.SCORE,
+ title: 'Preferred word score'
+ }),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, {
+ name: icons.DANGER,
+ title: 'Rejections'
+ }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ },
+ {
+ name: 'releaseWeight',
+ label: React.createElement(Icon, { name: icons.DOWNLOAD }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ }
+];
+
+function InteractiveSearch(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ totalReleasesCount,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ type,
+ longDateFormat,
+ timeFormat,
+ onSortPress,
+ onFilterSelect,
+ onGrabPress
+ } = props;
+
+ return (
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ Unable to load results for this episode search. Try again later
+
+ }
+
+ {
+ !isFetching && isPopulated && !totalReleasesCount &&
+
+ No results found
+
+ }
+
+ {
+ !!totalReleasesCount && isPopulated && !items.length &&
+
+ All results are hidden by the applied filter
+
+ }
+
+ {
+ isPopulated && !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ totalReleasesCount !== items.length && !!items.length &&
+
+ Some results are hidden by the applied filter
+
+ }
+
+ );
+}
+
+InteractiveSearch.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalReleasesCount: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onGrabPress: PropTypes.func.isRequired
+};
+
+export default InteractiveSearch;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js
new file mode 100644
index 000000000..601f337d6
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as releaseActions from 'Store/Actions/releaseActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import InteractiveSearch from './InteractiveSearch';
+
+function createMapStateToProps(appState, { type }) {
+ return createSelector(
+ (state) => state.releases.items.length,
+ createClientSideCollectionSelector('releases', `releases.${type}`),
+ createUISettingsSelector(),
+ (totalReleasesCount, releases, uiSettings) => {
+ return {
+ totalReleasesCount,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ ...releases
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchReleases(payload) {
+ dispatch(releaseActions.fetchReleases(payload));
+ },
+
+ onSortPress(sortKey, sortDirection) {
+ dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection }));
+ },
+
+ onFilterSelect(selectedFilterKey) {
+ const action = props.type === 'episode' ?
+ releaseActions.setEpisodeReleasesFilter :
+ releaseActions.setSeasonReleasesFilter;
+
+ dispatch(action({ selectedFilterKey }));
+ },
+
+ onGrabPress(guid, indexerId) {
+ dispatch(releaseActions.grabRelease({ guid, indexerId }));
+ }
+ };
+}
+
+class InteractiveSearchConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ searchPayload,
+ isPopulated,
+ dispatchFetchReleases
+ } = this.props;
+
+ // If search results are not yet isPopulated fetch them,
+ // otherwise re-show the existing props.
+
+ if (!isPopulated) {
+ dispatchFetchReleases(searchPayload);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchReleases,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ );
+ }
+}
+
+InteractiveSearchConnector.propTypes = {
+ searchPayload: PropTypes.object.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ dispatchFetchReleases: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js
new file mode 100644
index 000000000..dcbcf340f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setEpisodeReleasesFilter, setSeasonReleasesFilter } from 'Store/Actions/releaseActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.releases.items,
+ (state) => state.releases.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'releases'
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchSetFilter(payload) {
+ const action = props.type === 'episode' ?
+ setEpisodeReleasesFilter:
+ setSeasonReleasesFilter;
+
+ dispatch(action(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal);
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
new file mode 100644
index 000000000..98503e496
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
@@ -0,0 +1,38 @@
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality,
+.language {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
+
+.language {
+ width: 100px;
+}
+
+.preferredWordScore {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 55px;
+ font-weight: bold;
+ cursor: default;
+}
+
+.rejected,
+.download {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.age,
+.size {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
new file mode 100644
index 000000000..4f8c5089f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
@@ -0,0 +1,217 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Link from 'Components/Link/Link';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import Peers from './Peers';
+import styles from './InteractiveSearchRow.css';
+
+function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
+ if (isGrabbing) {
+ return icons.SPINNER;
+ } else if (isGrabbed) {
+ return icons.DOWNLOADING;
+ } else if (grabError) {
+ return icons.DOWNLOADING;
+ }
+
+ return icons.DOWNLOAD;
+}
+
+function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
+ if (isGrabbing) {
+ return '';
+ } else if (isGrabbed) {
+ return 'Added to downloaded queue';
+ } else if (grabError) {
+ return grabError;
+ }
+
+ return 'Add to downloaded queue';
+}
+
+class InteractiveSearchRow extends Component {
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ const {
+ guid,
+ indexerId,
+ onGrabPress
+ }= this.props;
+
+ onGrabPress(guid, indexerId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ protocol,
+ age,
+ ageHours,
+ ageMinutes,
+ publishDate,
+ title,
+ infoUrl,
+ indexer,
+ size,
+ seeders,
+ leechers,
+ quality,
+ language,
+ preferredWordScore,
+ rejections,
+ downloadAllowed,
+ isGrabbing,
+ isGrabbed,
+ longDateFormat,
+ timeFormat,
+ grabError
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+ {formatAge(age, ageHours, ageMinutes)}
+
+
+
+
+ {title}
+
+
+
+
+ {indexer}
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ protocol === 'torrent' &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {preferredWordScore > 0 && `+${preferredWordScore}`}
+ {preferredWordScore < 0 && preferredWordScore}
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+ {
+ downloadAllowed &&
+
+ }
+
+
+ );
+ }
+}
+
+InteractiveSearchRow.propTypes = {
+ guid: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ age: PropTypes.number.isRequired,
+ ageHours: PropTypes.number.isRequired,
+ ageMinutes: PropTypes.number.isRequired,
+ publishDate: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ infoUrl: PropTypes.string.isRequired,
+ indexerId: PropTypes.number.isRequired,
+ indexer: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ seeders: PropTypes.number,
+ leechers: PropTypes.number,
+ quality: PropTypes.object.isRequired,
+ language: PropTypes.object.isRequired,
+ preferredWordScore: PropTypes.number.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
+ downloadAllowed: PropTypes.bool.isRequired,
+ isGrabbing: PropTypes.bool.isRequired,
+ isGrabbed: PropTypes.bool.isRequired,
+ grabError: PropTypes.string,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onGrabPress: PropTypes.func.isRequired
+};
+
+InteractiveSearchRow.defaultProps = {
+ rejections: [],
+ isGrabbing: false,
+ isGrabbed: false
+};
+
+export default InteractiveSearchRow;
diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/InteractiveSearch/Peers.js
new file mode 100644
index 000000000..66f7cc9f5
--- /dev/null
+++ b/frontend/src/InteractiveSearch/Peers.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function getKind(seeders) {
+ if (seeders > 50) {
+ return kinds.PRIMARY;
+ }
+
+ if (seeders > 10) {
+ return kinds.INFO;
+ }
+
+ if (seeders > 0) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+function getPeersTooltipPart(peers, peersUnit) {
+ if (peers == null) {
+ return `unknown ${peersUnit}s`;
+ }
+
+ if (peers === 1) {
+ return `1 ${peersUnit}`;
+ }
+
+ return `${peers} ${peersUnit}s`;
+}
+
+function Peers(props) {
+ const {
+ seeders,
+ leechers
+ } = props;
+
+ const kind = getKind(seeders);
+
+ return (
+
+ {seeders == null ? '-' : seeders} / {leechers == null ? '-' : leechers}
+
+ );
+}
+
+Peers.propTypes = {
+ seeders: PropTypes.number,
+ leechers: PropTypes.number
+};
+
+export default Peers;
diff --git a/frontend/src/Organize/OrganizePreviewModal.js b/frontend/src/Organize/OrganizePreviewModal.js
new file mode 100644
index 000000000..647f4ddf8
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import OrganizePreviewModalContentConnector from './OrganizePreviewModalContentConnector';
+
+function OrganizePreviewModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+OrganizePreviewModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizePreviewModal;
diff --git a/frontend/src/Organize/OrganizePreviewModalConnector.js b/frontend/src/Organize/OrganizePreviewModalConnector.js
new file mode 100644
index 000000000..ace733c86
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearOrganizePreview } from 'Store/Actions/organizePreviewActions';
+import OrganizePreviewModal from './OrganizePreviewModal';
+
+const mapDispatchToProps = {
+ clearOrganizePreview
+};
+
+class OrganizePreviewModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearOrganizePreview();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OrganizePreviewModalConnector.propTypes = {
+ clearOrganizePreview: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(OrganizePreviewModalConnector);
diff --git a/frontend/src/Organize/OrganizePreviewModalContent.css b/frontend/src/Organize/OrganizePreviewModalContent.css
new file mode 100644
index 000000000..7de056fcc
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.css
@@ -0,0 +1,24 @@
+.path {
+ margin-left: 5px;
+ font-weight: bold;
+}
+
+.episodeFormat {
+ margin-left: 5px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.previews {
+ margin-top: 10px;
+}
+
+.selectAllInputContainer {
+ margin-right: auto;
+ line-height: 30px;
+}
+
+.selectAllInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js
new file mode 100644
index 000000000..6962486df
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.js
@@ -0,0 +1,203 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 CheckInput from 'Components/Form/CheckInput';
+import SeasonNumber from 'Season/SeasonNumber';
+import OrganizePreviewRow from './OrganizePreviewRow';
+import styles from './OrganizePreviewModalContent.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+class OrganizePreviewModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {}
+ };
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onOrganizePress = () => {
+ this.props.onOrganizePress(this.getSelectedIds());
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ seasonNumber,
+ renameEpisodes,
+ episodeFormat,
+ path,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const selectAllValue = getValue(allSelected, allUnselected);
+
+ return (
+
+
+ Organize & Rename {seasonNumber != null && }
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Error loading previews
+ }
+
+ {
+ !isFetching && isPopulated && !items.length &&
+
+ {
+ renameEpisodes ?
+
Success! My work is done, no files to rename.
:
+
Renaming is disabled, nothing to rename
+ }
+
+ }
+
+ {
+ !isFetching && isPopulated && !!items.length &&
+
+
+
+ All paths are relative to:
+
+ {path}
+
+
+
+
+ Naming pattern:
+
+ {episodeFormat}
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ {
+ isPopulated && !!items.length &&
+
+ }
+
+
+ Cancel
+
+
+
+ Organize
+
+
+
+ );
+ }
+}
+
+OrganizePreviewModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ seasonNumber: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ renameEpisodes: PropTypes.bool,
+ episodeFormat: PropTypes.string,
+ onOrganizePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizePreviewModalContent;
diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js
new file mode 100644
index 000000000..cbb150407
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.js
@@ -0,0 +1,91 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions';
+import { fetchNamingSettings } from 'Store/Actions/settingsActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import OrganizePreviewModalContent from './OrganizePreviewModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.organizePreview,
+ (state) => state.settings.naming,
+ createSeriesSelector(),
+ (organizePreview, naming, series) => {
+ const props = { ...organizePreview };
+ props.isFetching = organizePreview.isFetching || naming.isFetching;
+ props.isPopulated = organizePreview.isPopulated && naming.isPopulated;
+ props.error = organizePreview.error || naming.error;
+ props.renameEpisodes = naming.item.renameEpisodes;
+ props.episodeFormat = naming.item[`${series.seriesType}EpisodeFormat`];
+ props.path = series.path;
+
+ return props;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchOrganizePreview,
+ fetchNamingSettings,
+ executeCommand
+};
+
+class OrganizePreviewModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.fetchOrganizePreview({
+ seriesId,
+ seasonNumber
+ });
+
+ this.props.fetchNamingSettings();
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = (files) => {
+ this.props.executeCommand({
+ name: commandNames.RENAME_FILES,
+ seriesId: this.props.seriesId,
+ files
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OrganizePreviewModalContentConnector.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number,
+ fetchOrganizePreview: PropTypes.func.isRequired,
+ fetchNamingSettings: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OrganizePreviewModalContentConnector);
diff --git a/frontend/src/Organize/OrganizePreviewRow.css b/frontend/src/Organize/OrganizePreviewRow.css
new file mode 100644
index 000000000..1b3c8ca47
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewRow.css
@@ -0,0 +1,20 @@
+.row {
+ display: flex;
+ margin-bottom: 5px;
+ padding: 5px 0;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+ }
+}
+
+.selectedContainer {
+ margin-right: 30px;
+}
+
+.path {
+ margin-left: 10px;
+}
diff --git a/frontend/src/Organize/OrganizePreviewRow.js b/frontend/src/Organize/OrganizePreviewRow.js
new file mode 100644
index 000000000..340232a98
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewRow.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './OrganizePreviewRow.css';
+
+class OrganizePreviewRow extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value: true });
+ }
+
+ //
+ // Listeners
+
+ onSelectedChange = ({ value, shiftKey }) => {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value, shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ existingPath,
+ newPath,
+ isSelected
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ {existingPath}
+
+
+
+
+
+
+
+ {newPath}
+
+
+
+
+ );
+ }
+}
+
+OrganizePreviewRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ existingPath: PropTypes.string.isRequired,
+ newPath: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default OrganizePreviewRow;
diff --git a/frontend/src/Season/SeasonNumber.js b/frontend/src/Season/SeasonNumber.js
new file mode 100644
index 000000000..8db967243
--- /dev/null
+++ b/frontend/src/Season/SeasonNumber.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+
+function SeasonNumber(props) {
+ const {
+ seasonNumber,
+ separator
+ } = props;
+
+ if (seasonNumber === 0) {
+ return `${separator}Specials`;
+ }
+
+ if (seasonNumber > 0) {
+ return `${separator}Season ${seasonNumber}`;
+ }
+
+ return null;
+}
+
+SeasonNumber.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ separator: PropTypes.string.isRequired
+};
+
+SeasonNumber.defaultProps = {
+ separator: '- '
+};
+
+export default SeasonNumber;
diff --git a/frontend/src/SeasonPass/SeasonPass.js b/frontend/src/SeasonPass/SeasonPass.js
new file mode 100644
index 000000000..7907f9249
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPass.js
@@ -0,0 +1,217 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import NoSeries from 'Series/NoSeries';
+import SeasonPassFilterModalConnector from './SeasonPassFilterModalConnector';
+import SeasonPassFooter from './SeasonPassFooter';
+import SeasonPassRowConnector from './SeasonPassRowConnector';
+
+const columns = [
+ {
+ name: 'status',
+ isVisible: true
+ },
+ {
+ name: 'sortTitle',
+ label: 'Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'monitored',
+ isVisible: true
+ },
+ {
+ name: 'seasonCount',
+ label: 'Seasons',
+ isSortable: true,
+ isVisible: true
+ }
+];
+
+class SeasonPass extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {}
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onUpdateSelectedPress = (changes) => {
+ this.props.onUpdateSelectedPress({
+ seriesIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ totalItems,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+SeasonPass.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalItems: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onUpdateSelectedPress: PropTypes.func.isRequired
+};
+
+export default SeasonPass;
diff --git a/frontend/src/SeasonPass/SeasonPassConnector.js b/frontend/src/SeasonPass/SeasonPassConnector.js
new file mode 100644
index 000000000..990c5f264
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassConnector.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setSeasonPassSort, setSeasonPassFilter, saveSeasonPass } from 'Store/Actions/seasonPassActions';
+import SeasonPass from './SeasonPass';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('series', 'seasonPass'),
+ (series) => {
+ return {
+ ...series
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setSeasonPassSort,
+ setSeasonPassFilter,
+ saveSeasonPass
+};
+
+class SeasonPassConnector extends Component {
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.setSeasonPassSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setSeasonPassFilter({ selectedFilterKey });
+ }
+
+ onUpdateSelectedPress = (payload) => {
+ this.props.saveSeasonPass(payload);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeasonPassConnector.propTypes = {
+ setSeasonPassSort: PropTypes.func.isRequired,
+ setSeasonPassFilter: PropTypes.func.isRequired,
+ saveSeasonPass: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeasonPassConnector);
diff --git a/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js b/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js
new file mode 100644
index 000000000..ba3308cea
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setSeasonPassFilter } from 'Store/Actions/seasonPassActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.series.items,
+ (state) => state.seasonPass.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'seasonPass'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setSeasonPassFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/SeasonPass/SeasonPassFooter.css b/frontend/src/SeasonPass/SeasonPassFooter.css
new file mode 100644
index 000000000..c18eb660f
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassFooter.css
@@ -0,0 +1,14 @@
+.inputContainer {
+ margin-right: 20px;
+}
+
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.updateSelectedButton {
+ composes: button from 'Components/Link/SpinnerButton.css';
+
+ height: 35px;
+}
diff --git a/frontend/src/SeasonPass/SeasonPassFooter.js b/frontend/src/SeasonPass/SeasonPassFooter.js
new file mode 100644
index 000000000..7ce8dc491
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassFooter.js
@@ -0,0 +1,145 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import MonitorEpisodesSelectInput from 'Components/Form/MonitorEpisodesSelectInput';
+import SelectInput from 'Components/Form/SelectInput';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import styles from './SeasonPassFooter.css';
+
+const NO_CHANGE = 'noChange';
+
+class SeasonPassFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ monitored: NO_CHANGE,
+ monitor: NO_CHANGE
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = prevProps;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.setState({
+ monitored: NO_CHANGE,
+ monitor: NO_CHANGE
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ }
+
+ onUpdateSelectedPress = () => {
+ const {
+ monitor,
+ monitored
+ } = this.state;
+
+ const changes = {};
+
+ if (monitored !== NO_CHANGE) {
+ changes.monitored = monitored === 'monitored';
+ }
+
+ if (monitor !== NO_CHANGE) {
+ changes.monitor = monitor;
+ }
+
+ this.props.onUpdateSelectedPress(changes);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedCount,
+ isSaving
+ } = this.props;
+
+ const {
+ monitored,
+ monitor
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored' },
+ { key: 'unmonitored', value: 'Unmonitored' }
+ ];
+
+ const noChanges = monitored === NO_CHANGE && monitor === NO_CHANGE;
+
+ return (
+
+
+
+ Monitor Series
+
+
+
+
+
+
+
+ Monitor Episodes
+
+
+
+
+
+
+
+ {selectedCount} Series Selected
+
+
+
+ Update Selected
+
+
+
+ );
+ }
+}
+
+SeasonPassFooter.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ onUpdateSelectedPress: PropTypes.func.isRequired
+};
+
+export default SeasonPassFooter;
diff --git a/frontend/src/SeasonPass/SeasonPassRow.css b/frontend/src/SeasonPass/SeasonPassRow.css
new file mode 100644
index 000000000..a053c6bef
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassRow.css
@@ -0,0 +1,20 @@
+.status,
+.monitored {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 1px;
+ white-space: nowrap;
+}
+
+.seasons {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/SeasonPass/SeasonPassRow.js b/frontend/src/SeasonPass/SeasonPassRow.js
new file mode 100644
index 000000000..6da75ded9
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassRow.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import SeasonPassSeason from './SeasonPassSeason';
+import styles from './SeasonPassRow.css';
+
+class SeasonPassRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ seriesId,
+ status,
+ titleSlug,
+ title,
+ monitored,
+ seasons,
+ isSaving,
+ isSelected,
+ onSelectedChange,
+ onSeriesMonitoredPress,
+ onSeasonMonitoredPress
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ seasons.map((season) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+ }
+}
+
+SeasonPassRow.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ status: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ seasons: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onSeriesMonitoredPress: PropTypes.func.isRequired,
+ onSeasonMonitoredPress: PropTypes.func.isRequired
+};
+
+SeasonPassRow.defaultProps = {
+ isSaving: false
+};
+
+export default SeasonPassRow;
diff --git a/frontend/src/SeasonPass/SeasonPassRowConnector.js b/frontend/src/SeasonPass/SeasonPassRowConnector.js
new file mode 100644
index 000000000..f2139743f
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassRowConnector.js
@@ -0,0 +1,77 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import { toggleSeriesMonitored, toggleSeasonMonitored } from 'Store/Actions/seriesActions';
+import SeasonPassRow from './SeasonPassRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ (series) => {
+ return _.pick(series, [
+ 'status',
+ 'titleSlug',
+ 'title',
+ 'monitored',
+ 'seasons',
+ 'isSaving'
+ ]);
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ toggleSeriesMonitored,
+ toggleSeasonMonitored
+};
+
+class SeasonPassRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onSeriesMonitoredPress = () => {
+ const {
+ seriesId,
+ monitored
+ } = this.props;
+
+ this.props.toggleSeriesMonitored({
+ seriesId,
+ monitored: !monitored
+ });
+ }
+
+ onSeasonMonitoredPress = (seasonNumber, monitored) => {
+ this.props.toggleSeasonMonitored({
+ seriesId: this.props.seriesId,
+ seasonNumber,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeasonPassRowConnector.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ toggleSeriesMonitored: PropTypes.func.isRequired,
+ toggleSeasonMonitored: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeasonPassRowConnector);
diff --git a/frontend/src/SeasonPass/SeasonPassSeason.css b/frontend/src/SeasonPass/SeasonPassSeason.css
new file mode 100644
index 000000000..603d98ecd
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassSeason.css
@@ -0,0 +1,24 @@
+.season {
+ display: flex;
+ align-items: stretch;
+ overflow: hidden;
+ margin: 2px 4px;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: #eee;
+ cursor: default;
+}
+
+.info {
+ padding: 0 4px;
+}
+
+.episodes {
+ padding: 0 4px;
+ background-color: $white;
+ color: $defaultColor;
+}
+
+.allEpisodes {
+ background-color: #e0ffe0;
+}
diff --git a/frontend/src/SeasonPass/SeasonPassSeason.js b/frontend/src/SeasonPass/SeasonPassSeason.js
new file mode 100644
index 000000000..152221b8c
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassSeason.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import padNumber from 'Utilities/Number/padNumber';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import styles from './SeasonPassSeason.css';
+
+class SeasonPassSeason extends Component {
+
+ //
+ // Listeners
+
+ onSeasonMonitoredPress = () => {
+ const {
+ seasonNumber,
+ monitored
+ } = this.props;
+
+ this.props.onSeasonMonitoredPress(seasonNumber, !monitored);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ seasonNumber,
+ monitored,
+ statistics,
+ isSaving
+ } = this.props;
+
+ const {
+ episodeFileCount,
+ totalEpisodeCount,
+ percentOfEpisodes
+ } = statistics;
+
+ return (
+
+
+
+
+
+ {
+ seasonNumber === 0 ? 'Specials' : `S${padNumber(seasonNumber, 2)}`
+ }
+
+
+
+
+ {
+ totalEpisodeCount === 0 ? '0/0' : `${episodeFileCount}/${totalEpisodeCount}`
+ }
+
+
+ );
+ }
+}
+
+SeasonPassSeason.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ statistics: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onSeasonMonitoredPress: PropTypes.func.isRequired
+};
+
+SeasonPassSeason.defaultProps = {
+ isSaving: false,
+ statistics: {
+ episodeFileCount: 0,
+ totalEpisodeCount: 0,
+ percentOfEpisodes: 0
+ }
+};
+
+export default SeasonPassSeason;
diff --git a/frontend/src/Series/Delete/DeleteSeriesModal.js b/frontend/src/Series/Delete/DeleteSeriesModal.js
new file mode 100644
index 000000000..486ba3764
--- /dev/null
+++ b/frontend/src/Series/Delete/DeleteSeriesModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import DeleteSeriesModalContentConnector from './DeleteSeriesModalContentConnector';
+
+function DeleteSeriesModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteSeriesModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteSeriesModal;
diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContent.css b/frontend/src/Series/Delete/DeleteSeriesModalContent.css
new file mode 100644
index 000000000..dbfef0871
--- /dev/null
+++ b/frontend/src/Series/Delete/DeleteSeriesModalContent.css
@@ -0,0 +1,12 @@
+.pathContainer {
+ margin-bottom: 20px;
+}
+
+.pathIcon {
+ margin-right: 8px;
+}
+
+.deleteFilesMessage {
+ margin-top: 20px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContent.js b/frontend/src/Series/Delete/DeleteSeriesModalContent.js
new file mode 100644
index 000000000..b183ffdeb
--- /dev/null
+++ b/frontend/src/Series/Delete/DeleteSeriesModalContent.js
@@ -0,0 +1,144 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+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 styles from './DeleteSeriesModalContent.css';
+
+class DeleteSeriesModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onDeleteSeriesConfirmed = () => {
+ const deleteFiles = this.state.deleteFiles;
+
+ this.setState({ deleteFiles: false });
+ this.props.onDeletePress(deleteFiles);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ path,
+ statistics,
+ onModalClose
+ } = this.props;
+
+ const {
+ episodeFileCount,
+ sizeOnDisk
+ } = statistics;
+
+ const deleteFiles = this.state.deleteFiles;
+ let deleteFilesLabel = `Delete ${episodeFileCount} Episode Files`;
+ let deleteFilesHelpText = 'Delete the episode files and series folder';
+
+ if (episodeFileCount === 0) {
+ deleteFilesLabel = 'Delete Series Folder';
+ deleteFilesHelpText = 'Delete the series folder and it\'s contents';
+ }
+
+ return (
+
+
+ Delete - {title}
+
+
+
+
+
+
+ {path}
+
+
+
+ {deleteFilesLabel}
+
+
+
+
+ {
+ deleteFiles &&
+
+
The series folder {path} and all it's content will be deleted.
+
+ {
+ !!episodeFileCount &&
+
{episodeFileCount} episode files totaling {formatBytes(sizeOnDisk)}
+ }
+
+ }
+
+
+
+
+
+ Close
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteSeriesModalContent.propTypes = {
+ title: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+DeleteSeriesModalContent.defaultProps = {
+ statistics: {
+ episodeFileCount: 0
+ }
+};
+
+export default DeleteSeriesModalContent;
diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js
new file mode 100644
index 000000000..73033f957
--- /dev/null
+++ b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import { deleteSeries } from 'Store/Actions/seriesActions';
+import DeleteSeriesModalContent from './DeleteSeriesModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ (series) => {
+ return series;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteSeries
+};
+
+class DeleteSeriesModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = (deleteFiles) => {
+ this.props.deleteSeries({
+ id: this.props.seriesId,
+ deleteFiles
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DeleteSeriesModalContentConnector.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ deleteSeries: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DeleteSeriesModalContentConnector);
diff --git a/frontend/src/Series/Details/EpisodeRow.css b/frontend/src/Series/Details/EpisodeRow.css
new file mode 100644
index 000000000..2ff09f37c
--- /dev/null
+++ b/frontend/src/Series/Details/EpisodeRow.css
@@ -0,0 +1,32 @@
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
+
+.monitored {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 42px;
+}
+
+.episodeNumber {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.size {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.language,
+.audio,
+.video,
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Series/Details/EpisodeRow.js b/frontend/src/Series/Details/EpisodeRow.js
new file mode 100644
index 000000000..b6e951aa8
--- /dev/null
+++ b/frontend/src/Series/Details/EpisodeRow.js
@@ -0,0 +1,284 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
+import EpisodeNumber from 'Episode/EpisodeNumber';
+import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
+import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
+import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
+import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector';
+import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes';
+
+import styles from './EpisodeRow.css';
+
+class EpisodeRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onManualSearchPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ onMonitorEpisodePress = (monitored, options) => {
+ this.props.onMonitorEpisodePress(this.props.id, monitored, options);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ seriesId,
+ episodeFileId,
+ monitored,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ airDateUtc,
+ title,
+ unverifiedSceneNumbering,
+ isSaving,
+ seriesMonitored,
+ seriesType,
+ episodeFilePath,
+ episodeFileRelativePath,
+ episodeFileSize,
+ alternateTitles,
+ columns
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'monitored') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeNumber') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'title') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {
+ episodeFilePath
+ }
+
+ );
+ }
+
+ if (name === 'relativePath') {
+ return (
+
+ {
+ episodeFileRelativePath
+ }
+
+ );
+ }
+
+ if (name === 'airDateUtc') {
+ return (
+
+ );
+ }
+
+ if (name === 'language') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'audioInfo') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'videoCodec') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'size') {
+ return (
+
+ {!!episodeFileSize && formatBytes(episodeFileSize)}
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+ }
+}
+
+EpisodeRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ seriesId: PropTypes.number.isRequired,
+ episodeFileId: PropTypes.number,
+ monitored: PropTypes.bool.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ airDateUtc: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ isSaving: PropTypes.bool,
+ unverifiedSceneNumbering: PropTypes.bool,
+ seriesMonitored: PropTypes.bool.isRequired,
+ seriesType: PropTypes.string.isRequired,
+ episodeFilePath: PropTypes.string,
+ episodeFileRelativePath: PropTypes.string,
+ episodeFileSize: PropTypes.number,
+ mediaInfo: PropTypes.object,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMonitorEpisodePress: PropTypes.func.isRequired
+};
+
+EpisodeRow.defaultProps = {
+ alternateTitles: []
+};
+
+export default EpisodeRow;
diff --git a/frontend/src/Series/Details/EpisodeRowConnector.js b/frontend/src/Series/Details/EpisodeRowConnector.js
new file mode 100644
index 000000000..d8453cef3
--- /dev/null
+++ b/frontend/src/Series/Details/EpisodeRowConnector.js
@@ -0,0 +1,24 @@
+/* eslint max-params: 0 */
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import EpisodeRow from './EpisodeRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ createEpisodeFileSelector(),
+ (series = {}, episodeFile) => {
+ return {
+ seriesMonitored: series.monitored,
+ seriesType: series.seriesType,
+ episodeFilePath: episodeFile ? episodeFile.path : null,
+ episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null,
+ episodeFileSize: episodeFile ? episodeFile.size : null,
+ alternateTitles: series.alternateTitles
+ };
+ }
+ );
+}
+export default connect(createMapStateToProps)(EpisodeRow);
diff --git a/frontend/src/Series/Details/SeasonInfo.css b/frontend/src/Series/Details/SeasonInfo.css
new file mode 100644
index 000000000..3f4c3ac62
--- /dev/null
+++ b/frontend/src/Series/Details/SeasonInfo.css
@@ -0,0 +1,11 @@
+.title {
+ composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css';
+
+ width: 90px;
+}
+
+.description {
+ composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
+
+ margin-left: 110px;
+}
diff --git a/frontend/src/Series/Details/SeasonInfo.js b/frontend/src/Series/Details/SeasonInfo.js
new file mode 100644
index 000000000..f89542b49
--- /dev/null
+++ b/frontend/src/Series/Details/SeasonInfo.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './SeasonInfo.css';
+
+function SeasonInfo(props) {
+ const {
+ totalEpisodeCount,
+ monitoredEpisodeCount,
+ episodeFileCount
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+SeasonInfo.propTypes = {
+ totalEpisodeCount: PropTypes.number.isRequired,
+ monitoredEpisodeCount: PropTypes.number.isRequired,
+ episodeFileCount: PropTypes.number.isRequired
+};
+
+export default SeasonInfo;
diff --git a/frontend/src/Series/Details/SeriesAlternateTitles.css b/frontend/src/Series/Details/SeriesAlternateTitles.css
new file mode 100644
index 000000000..1af1ae68b
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesAlternateTitles.css
@@ -0,0 +1,3 @@
+.alternateTitle {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Series/Details/SeriesAlternateTitles.js b/frontend/src/Series/Details/SeriesAlternateTitles.js
new file mode 100644
index 000000000..18d016579
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesAlternateTitles.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './SeriesAlternateTitles.css';
+
+function SeriesAlternateTitles({ alternateTitles }) {
+ return (
+
+ {
+ alternateTitles.map((alternateTitle) => {
+ return (
+
+ {alternateTitle}
+
+ );
+ })
+ }
+
+ );
+}
+
+SeriesAlternateTitles.propTypes = {
+ alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default SeriesAlternateTitles;
diff --git a/frontend/src/Series/Details/SeriesDetails.css b/frontend/src/Series/Details/SeriesDetails.css
new file mode 100644
index 000000000..f161e524a
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetails.css
@@ -0,0 +1,155 @@
+.innerContentBody {
+ padding: 0;
+}
+
+.header {
+ position: relative;
+ width: 100%;
+ height: 425px;
+}
+
+.backdrop {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+}
+
+.backdropOverlay {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background: $black;
+ opacity: 0.7;
+}
+
+.headerContent {
+ display: flex;
+ padding: 30px;
+ width: 100%;
+ height: 100%;
+ color: $white;
+}
+
+.poster {
+ flex-shrink: 0;
+ margin-right: 35px;
+ width: 250px;
+ height: 368px;
+}
+
+.info {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.titleRow {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 auto;
+}
+
+.titleContainer {
+ display: flex;
+ margin-bottom: 5px;
+}
+
+.title {
+ font-weight: 300;
+ font-size: 50px;
+ line-height: 50px;
+}
+
+.toggleMonitoredContainer {
+ align-self: center;
+ margin-right: 10px;
+}
+
+.monitorToggleButton {
+ composes: toggleButton from 'Components/MonitorToggleButton.css';
+
+ width: 40px;
+
+ &:hover {
+ color: $iconButtonHoverLightColor;
+ }
+}
+
+.alternateTitlesIconContainer {
+ align-self: flex-end;
+ margin-left: 20px;
+}
+
+.seriesNavigationButtons {
+ white-space: nowrap;
+}
+
+.seriesNavigationButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ margin-left: 5px;
+ width: 30px;
+ color: #e1e2e3;
+ white-space: nowrap;
+
+ &:hover {
+ color: $iconButtonHoverLightColor;
+ }
+}
+
+.details {
+ margin-bottom: 8px;
+ font-weight: 300;
+ font-size: 20px;
+}
+
+.runtime {
+ margin-right: 15px;
+}
+
+.detailsLabel {
+ composes: label from 'Components/Label.css';
+
+ margin: 5px 10px 5px 0;
+}
+
+.path,
+.sizeOnDisk,
+.qualityProfileName,
+.network,
+.links,
+.tags {
+ margin-left: 8px;
+ font-weight: 300;
+ font-size: 17px;
+}
+
+.overview {
+ flex: 1 0 auto;
+ margin-top: 8px;
+ min-height: 0;
+ font-size: $intermediateFontSize;
+}
+
+.contentContainer {
+ padding: 20px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentContainer {
+ padding: 20px 0;
+ }
+
+ .headerContent {
+ padding: 15px;
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .poster {
+ display: none;
+ }
+}
diff --git a/frontend/src/Series/Details/SeriesDetails.js b/frontend/src/Series/Details/SeriesDetails.js
new file mode 100644
index 000000000..af693dcb9
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetails.js
@@ -0,0 +1,695 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
+import fonts from 'Styles/Variables/fonts';
+import HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+import Measure from 'Components/Measure';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import Popover from 'Components/Tooltip/Popover';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import SeriesPoster from 'Series/SeriesPoster';
+import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
+import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
+import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
+import SeriesAlternateTitles from './SeriesAlternateTitles';
+import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector';
+import SeriesTagsConnector from './SeriesTagsConnector';
+import SeriesDetailsLinks from './SeriesDetailsLinks';
+import styles from './SeriesDetails.css';
+import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
+
+const defaultFontSize = parseInt(fonts.defaultFontSize);
+const lineHeight = parseFloat(fonts.lineHeight);
+
+function getFanartUrl(images) {
+ const fanartImage = _.find(images, { coverType: 'fanart' });
+ if (fanartImage) {
+ // Remove protocol
+ return fanartImage.url.replace(/^https?:/, '');
+ }
+}
+
+function getExpandedState(newState) {
+ return {
+ allExpanded: newState.allSelected,
+ allCollapsed: newState.allUnselected,
+ expandedState: newState.selectedState
+ };
+}
+
+class SeriesDetails extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isManageEpisodesOpen: false,
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: false,
+ isSeriesHistoryModalOpen: false,
+ isInteractiveImportModalOpen: false,
+ allExpanded: false,
+ allCollapsed: false,
+ expandedState: {},
+ overviewHeight: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onManageEpisodesPress = () => {
+ this.setState({ isManageEpisodesOpen: true });
+ }
+
+ onManageEpisodesModalClose = () => {
+ this.setState({ isManageEpisodesOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditSeriesModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditSeriesModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteSeriesModalOpen: false });
+ }
+
+ onSeriesHistoryPress = () => {
+ this.setState({ isSeriesHistoryModalOpen: true });
+ }
+
+ onSeriesHistoryModalClose = () => {
+ this.setState({ isSeriesHistoryModalOpen: false });
+ }
+
+ onExpandAllPress = () => {
+ const {
+ allExpanded,
+ expandedState
+ } = this.state;
+
+ this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
+ }
+
+ onExpandPress = (seasonNumber, isExpanded) => {
+ this.setState((state) => {
+ const convertedState = {
+ allSelected: state.allExpanded,
+ allUnselected: state.allCollapsed,
+ selectedState: state.expandedState
+ };
+
+ const newState = toggleSelected(convertedState, [], seasonNumber, isExpanded, false);
+
+ return getExpandedState(newState);
+ });
+ }
+
+ onMeasure = ({ height }) => {
+ this.setState({ overviewHeight: height });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ tvdbId,
+ tvMazeId,
+ imdbId,
+ title,
+ runtime,
+ ratings,
+ path,
+ statistics,
+ qualityProfileId,
+ monitored,
+ status,
+ network,
+ overview,
+ images,
+ seasons,
+ alternateTitles,
+ tags,
+ isSaving,
+ isRefreshing,
+ isSearching,
+ isFetching,
+ isPopulated,
+ episodesError,
+ episodeFilesError,
+ hasEpisodes,
+ hasMonitoredEpisodes,
+ hasEpisodeFiles,
+ previousSeries,
+ nextSeries,
+ onMonitorTogglePress,
+ onRefreshPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ episodeFileCount,
+ sizeOnDisk
+ } = statistics;
+
+ const {
+ isOrganizeModalOpen,
+ isManageEpisodesOpen,
+ isEditSeriesModalOpen,
+ isDeleteSeriesModalOpen,
+ isSeriesHistoryModalOpen,
+ isInteractiveImportModalOpen,
+ allExpanded,
+ allCollapsed,
+ expandedState,
+ overviewHeight
+ } = this.state;
+
+ const continuing = status === 'continuing';
+
+ let episodeFilesCountMessage = 'No episode files';
+
+ if (episodeFileCount === 1) {
+ episodeFilesCountMessage = '1 episode file';
+ } else if (episodeFileCount > 1) {
+ episodeFilesCountMessage = `${episodeFileCount} episode files`;
+ }
+
+ let expandIcon = icons.EXPAND_INDETERMINATE;
+
+ if (allExpanded) {
+ expandIcon = icons.COLLAPSE;
+ } else if (allCollapsed) {
+ expandIcon = icons.EXPAND;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+ {
+ !!alternateTitles.length &&
+
+
+ }
+ title="Alternate Titles"
+ body={
}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !!runtime &&
+
+ {runtime} Minutes
+
+ }
+
+
+
+
+
+
+
+
+
+
+ {path}
+
+
+
+
+
+
+
+ {
+ formatBytes(sizeOnDisk)
+ }
+
+
+
+
+
+
+
+ {
+
+ }
+
+
+
+
+
+
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+
+
+
+
+
+
+ {continuing ? 'Continuing' : 'Ended'}
+
+
+
+ {
+ !!network &&
+
+
+
+
+ {network}
+
+
+ }
+
+
+
+
+
+ Links
+
+
+ }
+ tooltip={
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ {
+ !!tags.length &&
+
+
+
+
+ Tags
+
+
+ }
+ tooltip={ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !isPopulated && !episodesError && !episodeFilesError &&
+
+ }
+
+ {
+ !isFetching && episodesError &&
+
Loading episodes failed
+ }
+
+ {
+ !isFetching && episodeFilesError &&
+
Loading episode files failed
+ }
+
+ {
+ isPopulated && !!seasons.length &&
+
+ {
+ seasons.slice(0).reverse().map((season) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ isPopulated && !seasons.length &&
+
+ No episode information is available.
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesDetails.propTypes = {
+ id: PropTypes.number.isRequired,
+ tvdbId: PropTypes.number.isRequired,
+ tvMazeId: PropTypes.number,
+ imdbId: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ runtime: PropTypes.number.isRequired,
+ ratings: PropTypes.object.isRequired,
+ path: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ network: PropTypes.string,
+ overview: PropTypes.string.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ seasons: PropTypes.arrayOf(PropTypes.object).isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ isRefreshing: PropTypes.bool.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ episodesError: PropTypes.object,
+ episodeFilesError: PropTypes.object,
+ hasEpisodes: PropTypes.bool.isRequired,
+ hasMonitoredEpisodes: PropTypes.bool.isRequired,
+ hasEpisodeFiles: PropTypes.bool.isRequired,
+ previousSeries: PropTypes.object.isRequired,
+ nextSeries: PropTypes.object.isRequired,
+ onMonitorTogglePress: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+SeriesDetails.defaultProps = {
+ statistics: {},
+ tag: [],
+ isSaving: false
+};
+
+export default SeriesDetails;
diff --git a/frontend/src/Series/Details/SeriesDetailsConnector.js b/frontend/src/Series/Details/SeriesDetailsConnector.js
new file mode 100644
index 000000000..25bf13de1
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetailsConnector.js
@@ -0,0 +1,271 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand, isCommandExecuting } from 'Utilities/Command';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
+import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
+import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import SeriesDetails from './SeriesDetails';
+
+const selectEpisodes = createSelector(
+ (state) => state.episodes,
+ (episodes) => {
+ const {
+ items,
+ isFetching,
+ isPopulated,
+ error
+ } = episodes;
+
+ const hasEpisodes = !!items.length;
+ const hasMonitoredEpisodes = items.some((e) => e.monitored);
+
+ return {
+ isEpisodesFetching: isFetching,
+ isEpisodesPopulated: isPopulated,
+ episodesError: error,
+ hasEpisodes,
+ hasMonitoredEpisodes
+ };
+ }
+);
+
+const selectEpisodeFiles = createSelector(
+ (state) => state.episodeFiles,
+ (episodeFiles) => {
+ const {
+ items,
+ isFetching,
+ isPopulated,
+ error
+ } = episodeFiles;
+
+ const hasEpisodeFiles = !!items.length;
+
+ return {
+ isEpisodeFilesFetching: isFetching,
+ isEpisodeFilesPopulated: isPopulated,
+ episodeFilesError: error,
+ hasEpisodeFiles
+ };
+ }
+);
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { titleSlug }) => titleSlug,
+ selectEpisodes,
+ selectEpisodeFiles,
+ createAllSeriesSelector(),
+ createCommandsSelector(),
+ (titleSlug, episodes, episodeFiles, allSeries, commands) => {
+ const sortedSeries = _.orderBy(allSeries, 'sortTitle');
+ const seriesIndex = _.findIndex(sortedSeries, { titleSlug });
+ const series = sortedSeries[seriesIndex];
+
+ if (!series) {
+ return {};
+ }
+
+ const {
+ isEpisodesFetching,
+ isEpisodesPopulated,
+ episodesError,
+ hasEpisodes,
+ hasMonitoredEpisodes
+ } = episodes;
+
+ const {
+ isEpisodeFilesFetching,
+ isEpisodeFilesPopulated,
+ episodeFilesError,
+ hasEpisodeFiles
+ } = episodeFiles;
+
+ const previousSeries = sortedSeries[seriesIndex - 1] || _.last(sortedSeries);
+ const nextSeries = sortedSeries[seriesIndex + 1] || _.first(sortedSeries);
+ const isSeriesRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_SERIES, seriesId: series.id }));
+ const seriesRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_SERIES });
+ const allSeriesRefreshing = (
+ isCommandExecuting(seriesRefreshingCommand) &&
+ !seriesRefreshingCommand.body.seriesId
+ );
+ const isRefreshing = isSeriesRefreshing || allSeriesRefreshing;
+ const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.SERIES_SEARCH, seriesId: series.id }));
+ const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, seriesId: series.id }));
+ const isRenamingSeriesCommand = findCommand(commands, { name: commandNames.RENAME_SERIES });
+ const isRenamingSeries = (
+ isCommandExecuting(isRenamingSeriesCommand) &&
+ isRenamingSeriesCommand.body.seriesIds.indexOf(series.id) > -1
+ );
+
+ const isFetching = isEpisodesFetching || isEpisodeFilesFetching;
+ const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
+ const alternateTitles = _.reduce(series.alternateTitles, (acc, alternateTitle) => {
+ if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
+ (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
+ acc.push(alternateTitle.title);
+ }
+
+ return acc;
+ }, []);
+
+ return {
+ ...series,
+ alternateTitles,
+ isSeriesRefreshing,
+ allSeriesRefreshing,
+ isRefreshing,
+ isSearching,
+ isRenamingFiles,
+ isRenamingSeries,
+ isFetching,
+ isPopulated,
+ episodesError,
+ episodeFilesError,
+ hasEpisodes,
+ hasMonitoredEpisodes,
+ hasEpisodeFiles,
+ previousSeries,
+ nextSeries
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchEpisodes,
+ clearEpisodes,
+ fetchEpisodeFiles,
+ clearEpisodeFiles,
+ toggleSeriesMonitored,
+ fetchQueueDetails,
+ clearQueueDetails,
+ executeCommand
+};
+
+class SeriesDetailsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.populate);
+ this.populate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ isSeriesRefreshing,
+ allSeriesRefreshing,
+ isRenamingFiles,
+ isRenamingSeries
+ } = this.props;
+
+ if (
+ (prevProps.isSeriesRefreshing && !isSeriesRefreshing) ||
+ (prevProps.allSeriesRefreshing && !allSeriesRefreshing) ||
+ (prevProps.isRenamingFiles && !isRenamingFiles) ||
+ (prevProps.isRenamingSeries && !isRenamingSeries)
+ ) {
+ this.populate();
+ }
+
+ // If the id has changed we need to clear the episodes/episode
+ // files and fetch from the server.
+
+ if (prevProps.id !== id) {
+ this.unpopulate();
+ this.populate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.populate);
+ this.unpopulate();
+ }
+
+ //
+ // Control
+
+ populate = () => {
+ const seriesId = this.props.id;
+
+ this.props.fetchEpisodes({ seriesId });
+ this.props.fetchEpisodeFiles({ seriesId });
+ this.props.fetchQueueDetails({ seriesId });
+ }
+
+ unpopulate = () => {
+ this.props.clearEpisodes();
+ this.props.clearEpisodeFiles();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Listeners
+
+ onMonitorTogglePress = (monitored) => {
+ this.props.toggleSeriesMonitored({
+ seriesId: this.props.id,
+ monitored
+ });
+ }
+
+ onRefreshPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_SERIES,
+ seriesId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.SERIES_SEARCH,
+ seriesId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesDetailsConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ isSeriesRefreshing: PropTypes.bool.isRequired,
+ allSeriesRefreshing: PropTypes.bool.isRequired,
+ isRefreshing: PropTypes.bool.isRequired,
+ isRenamingFiles: PropTypes.bool.isRequired,
+ isRenamingSeries: PropTypes.bool.isRequired,
+ fetchEpisodes: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired,
+ fetchEpisodeFiles: PropTypes.func.isRequired,
+ clearEpisodeFiles: PropTypes.func.isRequired,
+ toggleSeriesMonitored: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsConnector);
diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.css b/frontend/src/Series/Details/SeriesDetailsLinks.css
new file mode 100644
index 000000000..0f65b9154
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetailsLinks.css
@@ -0,0 +1,13 @@
+.links {
+ margin: 0;
+}
+
+.link {
+ white-space: nowrap;
+}
+
+.linkLabel {
+ composes: label from 'Components/Label.css';
+
+ cursor: pointer;
+}
diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.js b/frontend/src/Series/Details/SeriesDetailsLinks.js
new file mode 100644
index 000000000..9cacdb1a3
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetailsLinks.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import styles from './SeriesDetailsLinks.css';
+
+function SeriesDetailsLinks(props) {
+ const {
+ tvdbId,
+ tvMazeId,
+ imdbId
+ } = props;
+
+ return (
+
+
+
+ The TVDB
+
+
+
+
+
+ Trakt
+
+
+
+ {
+ !!tvMazeId &&
+
+
+ TV Maze
+
+
+ }
+
+ {
+ !!imdbId &&
+
+
+ IMDB
+
+
+ }
+
+ );
+}
+
+SeriesDetailsLinks.propTypes = {
+ tvdbId: PropTypes.number.isRequired,
+ tvMazeId: PropTypes.number,
+ imdbId: PropTypes.string
+};
+
+export default SeriesDetailsLinks;
diff --git a/frontend/src/Series/Details/SeriesDetailsPageConnector.js b/frontend/src/Series/Details/SeriesDetailsPageConnector.js
new file mode 100644
index 000000000..bf440a532
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetailsPageConnector.js
@@ -0,0 +1,76 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { push } from 'react-router-redux';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import NotFound from 'Components/NotFound';
+import SeriesDetailsConnector from './SeriesDetailsConnector';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ createAllSeriesSelector(),
+ (match, allSeries) => {
+ const titleSlug = match.params.titleSlug;
+ const seriesIndex = _.findIndex(allSeries, { titleSlug });
+
+ if (seriesIndex > -1) {
+ return {
+ titleSlug
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ push
+};
+
+class SeriesDetailsPageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.titleSlug) {
+ this.props.push(`${window.Sonarr.urlBase}/`);
+ return;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ titleSlug
+ } = this.props;
+
+ if (!titleSlug) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+SeriesDetailsPageConnector.propTypes = {
+ titleSlug: PropTypes.string,
+ match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
+ push: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsPageConnector);
diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.css b/frontend/src/Series/Details/SeriesDetailsSeason.css
new file mode 100644
index 000000000..f417c415a
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetailsSeason.css
@@ -0,0 +1,112 @@
+.season {
+ margin-bottom: 20px;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-size: 24px;
+}
+
+.seasonNumber {
+ margin-right: 10px;
+ margin-left: 5px;
+}
+
+.episodeCountTooltip {
+ display: flex;
+}
+
+.expandButton {
+ composes: link from 'Components/Link/Link.css';
+
+ flex-grow: 1;
+ margin: 0 20px;
+ text-align: center;
+}
+
+.left {
+ display: flex;
+ align-items: center;
+ flex: 0 1 300px;
+}
+
+.left,
+.actions {
+ padding: 15px 10px;
+}
+
+.actionsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ flex: 0 0 45px;
+}
+
+.actionsMenuContent {
+ composes: menuContent from 'Components/Menu/MenuContent.css';
+
+ white-space: nowrap;
+ font-size: $defaultFontSize;
+}
+
+.actionMenuIcon {
+ margin-right: 8px;
+}
+
+.actionButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ width: 30px;
+}
+
+.episodes {
+ padding-top: 15px;
+ border-top: 1px solid $borderColor;
+}
+
+.collapseButtonContainer {
+ padding: 10px 15px;
+ width: 100%;
+ border-top: 1px solid $borderColor;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ background-color: #fafafa;
+ text-align: center;
+}
+
+.expandButtonIcon {
+ composes: actionButton;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -12px;
+ margin-left: -15px;
+}
+
+.noEpisodes {
+ margin-bottom: 15px;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .season {
+ border-right: 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+
+ .expandButtonIcon {
+ position: static;
+ margin: 0;
+ }
+}
diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.js b/frontend/src/Series/Details/SeriesDetailsSeason.js
new file mode 100644
index 000000000..f63ba9c30
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetailsSeason.js
@@ -0,0 +1,519 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import isAfter from 'Utilities/Date/isAfter';
+import isBefore from 'Utilities/Date/isBefore';
+import getToggledRange from 'Utilities/Table/getToggledRange';
+import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import SpinnerIcon from 'Components/SpinnerIcon';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import Popover from 'Components/Tooltip/Popover';
+import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
+import SeasonInteractiveSearchModalConnector from 'Series/Search/SeasonInteractiveSearchModalConnector';
+import EpisodeRowConnector from './EpisodeRowConnector';
+import SeasonInfo from './SeasonInfo';
+import styles from './SeriesDetailsSeason.css';
+
+function getSeasonStatistics(episodes) {
+ let episodeCount = 0;
+ let episodeFileCount = 0;
+ let totalEpisodeCount = 0;
+ let monitoredEpisodeCount = 0;
+ let hasMonitoredEpisodes = false;
+
+ episodes.forEach((episode) => {
+ if (episode.episodeFileId || (episode.monitored && isBefore(episode.airDateUtc))) {
+ episodeCount++;
+ }
+
+ if (episode.episodeFileId) {
+ episodeFileCount++;
+ }
+
+ if (episode.monitored) {
+ monitoredEpisodeCount++;
+ hasMonitoredEpisodes = true;
+ }
+
+ totalEpisodeCount++;
+ });
+
+ return {
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount,
+ monitoredEpisodeCount,
+ hasMonitoredEpisodes
+ };
+}
+
+function getEpisodeCountKind(monitored, episodeFileCount, episodeCount) {
+ if (episodeFileCount === episodeCount && episodeCount > 0) {
+ return kinds.SUCCESS;
+ }
+
+ if (!monitored) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+class SeriesDetailsSeason extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isManageEpisodesOpen: false,
+ isHistoryModalOpen: false,
+ isInteractiveSearchModalOpen: false,
+ lastToggledEpisode: null
+ };
+ }
+
+ componentDidMount() {
+ this._expandByDefault();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ seriesId,
+ items
+ } = this.props;
+
+ if (prevProps.seriesId !== seriesId) {
+ this._expandByDefault();
+ return;
+ }
+
+ if (
+ getSeasonStatistics(prevProps.items).episodeFileCount > 0 &&
+ getSeasonStatistics(items).episodeFileCount === 0
+ ) {
+ this.setState({
+ isOrganizeModalOpen: false,
+ isManageEpisodesOpen: false
+ });
+ }
+ }
+
+ //
+ // Control
+
+ _expandByDefault() {
+ const {
+ seasonNumber,
+ onExpandPress,
+ items
+ } = this.props;
+
+ const expand = _.some(items, (item) => {
+ return isAfter(item.airDateUtc) ||
+ isAfter(item.airDateUtc, { days: -30 });
+ });
+
+ onExpandPress(seasonNumber, expand && seasonNumber > 0);
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onManageEpisodesPress = () => {
+ this.setState({ isManageEpisodesOpen: true });
+ }
+
+ onManageEpisodesModalClose = () => {
+ this.setState({ isManageEpisodesOpen: false });
+ }
+
+ onHistoryPress = () => {
+ this.setState({ isHistoryModalOpen: true });
+ }
+
+ onHistoryModalClose = () => {
+ this.setState({ isHistoryModalOpen: false });
+ }
+
+ onInteractiveSearchPress = () => {
+ this.setState({ isInteractiveSearchModalOpen: true });
+ }
+
+ onInteractiveSearchModalClose = () => {
+ this.setState({ isInteractiveSearchModalOpen: false });
+ }
+
+ onExpandPress = () => {
+ const {
+ seasonNumber,
+ isExpanded
+ } = this.props;
+
+ this.props.onExpandPress(seasonNumber, !isExpanded);
+ }
+
+ onMonitorEpisodePress = (episodeId, monitored, { shiftKey }) => {
+ const lastToggled = this.state.lastToggledEpisode;
+ const episodeIds = [episodeId];
+
+ if (shiftKey && lastToggled) {
+ const { lower, upper } = getToggledRange(this.props.items, episodeId, lastToggled);
+ const items = this.props.items;
+
+ for (let i = lower; i < upper; i++) {
+ episodeIds.push(items[i].id);
+ }
+ }
+
+ this.setState({ lastToggledEpisode: episodeId });
+
+ this.props.onMonitorEpisodePress(_.uniq(episodeIds), monitored);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ seriesId,
+ monitored,
+ seasonNumber,
+ items,
+ columns,
+ isSaving,
+ isExpanded,
+ isSearching,
+ seriesMonitored,
+ isSmallScreen,
+ onTableOptionChange,
+ onMonitorSeasonPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount,
+ monitoredEpisodeCount,
+ hasMonitoredEpisodes
+ } = getSeasonStatistics(items);
+
+ const {
+ isOrganizeModalOpen,
+ isManageEpisodesOpen,
+ isHistoryModalOpen,
+ isInteractiveSearchModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+
+ {
+ seasonNumber === 0 ?
+
+ Specials
+ :
+
+ Season {seasonNumber}
+
+ }
+
+
+ {episodeFileCount} / {episodeCount}
+
+ }
+ title="Season Information"
+ body={
+
+
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+ {
+ isSmallScreen ?
+
+
+
+
+
+
+
+
+
+ Search
+
+
+
+
+
+ Interactive Search
+
+
+
+
+
+ Preview Rename
+
+
+
+
+
+ Manage Episodes
+
+
+
+
+
+ History
+
+
+ :
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+
+ {
+ isExpanded &&
+
+ {
+ items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+
+
+ No episodes in this season
+
+ }
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesDetailsSeason.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool,
+ isExpanded: PropTypes.bool,
+ isSearching: PropTypes.bool.isRequired,
+ seriesMonitored: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onMonitorSeasonPress: PropTypes.func.isRequired,
+ onExpandPress: PropTypes.func.isRequired,
+ onMonitorEpisodePress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+export default SeriesDetailsSeason;
diff --git a/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js
new file mode 100644
index 000000000..12b9a3166
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js
@@ -0,0 +1,118 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand, isCommandExecuting } from 'Utilities/Command';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
+import { toggleEpisodesMonitored, setEpisodesTableOption } from 'Store/Actions/episodeActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import SeriesDetailsSeason from './SeriesDetailsSeason';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seasonNumber }) => seasonNumber,
+ (state) => state.episodes,
+ createSeriesSelector(),
+ createCommandsSelector(),
+ createDimensionsSelector(),
+ (seasonNumber, episodes, series, commands, dimensions) => {
+ const isSearching = isCommandExecuting(findCommand(commands, {
+ name: commandNames.SEASON_SEARCH,
+ seriesId: series.id,
+ seasonNumber
+ }));
+
+ const episodesInSeason = _.filter(episodes.items, { seasonNumber });
+ const sortedEpisodes = _.orderBy(episodesInSeason, 'episodeNumber', 'desc');
+
+ return {
+ items: sortedEpisodes,
+ columns: episodes.columns,
+ isSearching,
+ seriesMonitored: series.monitored,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ toggleSeasonMonitored,
+ toggleEpisodesMonitored,
+ setEpisodesTableOption,
+ executeCommand
+};
+
+class SeriesDetailsSeasonConnector extends Component {
+
+ //
+ // Listeners
+
+ onTableOptionChange = (payload) => {
+ this.props.setEpisodesTableOption(payload);
+ }
+
+ onMonitorSeasonPress = (monitored) => {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.toggleSeasonMonitored({
+ seriesId,
+ seasonNumber,
+ monitored
+ });
+ }
+
+ onSearchPress = () => {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.executeCommand({
+ name: commandNames.SEASON_SEARCH,
+ seriesId,
+ seasonNumber
+ });
+ }
+
+ onMonitorEpisodePress = (episodeIds, monitored) => {
+ this.props.toggleEpisodesMonitored({
+ episodeIds,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesDetailsSeasonConnector.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ toggleSeasonMonitored: PropTypes.func.isRequired,
+ toggleEpisodesMonitored: PropTypes.func.isRequired,
+ setEpisodesTableOption: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsSeasonConnector);
diff --git a/frontend/src/Series/Details/SeriesTags.js b/frontend/src/Series/Details/SeriesTags.js
new file mode 100644
index 000000000..3876c9273
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesTags.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function SeriesTags({ tags }) {
+ return (
+
+ {
+ tags.map((tag) => {
+ return (
+
+ {tag}
+
+ );
+ })
+ }
+
+ );
+}
+
+SeriesTags.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default SeriesTags;
diff --git a/frontend/src/Series/Details/SeriesTagsConnector.js b/frontend/src/Series/Details/SeriesTagsConnector.js
new file mode 100644
index 000000000..e89bc1800
--- /dev/null
+++ b/frontend/src/Series/Details/SeriesTagsConnector.js
@@ -0,0 +1,30 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import SeriesTags from './SeriesTags';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ createTagsSelector(),
+ (series, tagList) => {
+ const tags = _.reduce(series.tags, (acc, tag) => {
+ const matchingTag = _.find(tagList, { id: tag });
+
+ if (matchingTag) {
+ acc.push(matchingTag.label);
+ }
+
+ return acc;
+ }, []);
+
+ return {
+ tags
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(SeriesTags);
diff --git a/frontend/src/Series/Edit/EditSeriesModal.js b/frontend/src/Series/Edit/EditSeriesModal.js
new file mode 100644
index 000000000..7c34f9586
--- /dev/null
+++ b/frontend/src/Series/Edit/EditSeriesModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditSeriesModalContentConnector from './EditSeriesModalContentConnector';
+
+function EditSeriesModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditSeriesModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditSeriesModal;
diff --git a/frontend/src/Series/Edit/EditSeriesModalConnector.js b/frontend/src/Series/Edit/EditSeriesModalConnector.js
new file mode 100644
index 000000000..2dfa43e31
--- /dev/null
+++ b/frontend/src/Series/Edit/EditSeriesModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditSeriesModal from './EditSeriesModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditSeriesModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'series' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditSeriesModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(EditSeriesModalConnector);
diff --git a/frontend/src/Series/Edit/EditSeriesModalContent.css b/frontend/src/Series/Edit/EditSeriesModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Series/Edit/EditSeriesModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Series/Edit/EditSeriesModalContent.js b/frontend/src/Series/Edit/EditSeriesModalContent.js
new file mode 100644
index 000000000..c237cc824
--- /dev/null
+++ b/frontend/src/Series/Edit/EditSeriesModalContent.js
@@ -0,0 +1,223 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
+import styles from './EditSeriesModalContent.css';
+
+class EditSeriesModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isConfirmMoveModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onSavePress = () => {
+ const {
+ isPathChanging,
+ onSavePress
+ } = this.props;
+
+ if (isPathChanging && !this.state.isConfirmMoveModalOpen) {
+ this.setState({ isConfirmMoveModalOpen: true });
+ } else {
+ this.setState({ isConfirmMoveModalOpen: false });
+
+ onSavePress(false);
+ }
+ }
+
+ onMoveSeriesPress = () => {
+ this.setState({ isConfirmMoveModalOpen: false });
+
+ this.props.onSavePress(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ item,
+ isSaving,
+ showLanguageProfile,
+ originalPath,
+ onInputChange,
+ onModalClose,
+ onDeleteSeriesPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ monitored,
+ seasonFolder,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ path,
+ tags
+ } = item;
+
+ return (
+
+
+ Edit - {title}
+
+
+
+
+
+
+
+
+ Delete
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+
+ );
+ }
+}
+
+EditSeriesModalContent.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ item: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ isPathChanging: PropTypes.bool.isRequired,
+ originalPath: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteSeriesPress: PropTypes.func.isRequired
+};
+
+export default EditSeriesModalContent;
diff --git a/frontend/src/Series/Edit/EditSeriesModalContentConnector.js b/frontend/src/Series/Edit/EditSeriesModalContentConnector.js
new file mode 100644
index 000000000..c1e6a8e9f
--- /dev/null
+++ b/frontend/src/Series/Edit/EditSeriesModalContentConnector.js
@@ -0,0 +1,120 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import { setSeriesValue, saveSeries } from 'Store/Actions/seriesActions';
+import EditSeriesModalContent from './EditSeriesModalContent';
+
+function createIsPathChangingSelector() {
+ return createSelector(
+ (state) => state.series.pendingChanges,
+ createSeriesSelector(),
+ (pendingChanges, series) => {
+ const path = pendingChanges.path;
+
+ if (path == null) {
+ return false;
+ }
+
+ return series.path !== path;
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.series,
+ (state) => state.settings.languageProfiles,
+ createSeriesSelector(),
+ createIsPathChangingSelector(),
+ (seriesState, languageProfiles, series, isPathChanging) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges
+ } = seriesState;
+
+ const seriesSettings = _.pick(series, [
+ 'monitored',
+ 'seasonFolder',
+ 'qualityProfileId',
+ 'languageProfileId',
+ 'seriesType',
+ 'path',
+ 'tags'
+ ]);
+
+ const settings = selectSettings(seriesSettings, pendingChanges, saveError);
+
+ return {
+ title: series.title,
+ isSaving,
+ saveError,
+ isPathChanging,
+ originalPath: series.path,
+ item: settings.settings,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetSeriesValue: setSeriesValue,
+ dispatchSaveSeries: saveSeries
+};
+
+class EditSeriesModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetSeriesValue({ name, value });
+ }
+
+ onSavePress = (moveFiles) => {
+ this.props.dispatchSaveSeries({
+ id: this.props.seriesId,
+ moveFiles
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditSeriesModalContentConnector.propTypes = {
+ seriesId: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ dispatchSetSeriesValue: PropTypes.func.isRequired,
+ dispatchSaveSeries: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditSeriesModalContentConnector);
diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js
new file mode 100644
index 000000000..84fca1612
--- /dev/null
+++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DeleteSeriesModalContentConnector from './DeleteSeriesModalContentConnector';
+
+function DeleteSeriesModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteSeriesModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteSeriesModal;
diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css
new file mode 100644
index 000000000..950fdc27d
--- /dev/null
+++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css
@@ -0,0 +1,13 @@
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.pathContainer {
+ margin-left: 5px;
+}
+
+.path {
+ margin-left: 5px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js
new file mode 100644
index 000000000..79c854cad
--- /dev/null
+++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Button from 'Components/Link/Button';
+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 styles from './DeleteSeriesModalContent.css';
+
+class DeleteSeriesModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onDeleteSeriesConfirmed = () => {
+ const deleteFiles = this.state.deleteFiles;
+
+ this.setState({ deleteFiles: false });
+ this.props.onDeleteSelectedPress(deleteFiles);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ series,
+ onModalClose
+ } = this.props;
+ const deleteFiles = this.state.deleteFiles;
+
+ return (
+
+
+ Delete Selected Series
+
+
+
+
+
+ {`Delete Series Folder${series.length > 1 ? 's' : ''}`}
+
+ 1 ? 's' : ''} and all contents`}
+ kind={kinds.DANGER}
+ onChange={this.onDeleteFilesChange}
+ />
+
+
+
+
+ {`Are you sure you want to delete ${series.length} selected series${deleteFiles ? ' and all contents' : ''}?`}
+
+
+
+ {
+ series.map((s) => {
+ return (
+
+ {s.title}
+
+ {
+ deleteFiles &&
+
+ -
+
+ {s.path}
+
+
+ }
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteSeriesModalContent.propTypes = {
+ series: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteSelectedPress: PropTypes.func.isRequired
+};
+
+export default DeleteSeriesModalContent;
diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js
new file mode 100644
index 000000000..0513842b7
--- /dev/null
+++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js
@@ -0,0 +1,45 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import { bulkDeleteSeries } from 'Store/Actions/seriesEditorActions';
+import DeleteSeriesModalContent from './DeleteSeriesModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seriesIds }) => seriesIds,
+ createAllSeriesSelector(),
+ (seriesIds, allSeries) => {
+ const selectedSeries = _.intersectionWith(allSeries, seriesIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedSeries = _.orderBy(selectedSeries, 'sortTitle');
+ const series = _.map(sortedSeries, (s) => {
+ return {
+ title: s.title,
+ path: s.path
+ };
+ });
+
+ return {
+ series
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDeleteSelectedPress(deleteFiles) {
+ dispatch(bulkDeleteSeries({
+ seriesIds: props.seriesIds,
+ deleteFiles
+ }));
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteSeriesModalContent);
diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js
new file mode 100644
index 000000000..c970392ec
--- /dev/null
+++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import OrganizeSeriesModalContentConnector from './OrganizeSeriesModalContentConnector';
+
+function OrganizeSeriesModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+OrganizeSeriesModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizeSeriesModal;
diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css
new file mode 100644
index 000000000..0b896f4ef
--- /dev/null
+++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css
@@ -0,0 +1,8 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js
new file mode 100644
index 000000000..10a459d52
--- /dev/null
+++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+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 styles from './OrganizeSeriesModalContent.css';
+
+function OrganizeSeriesModalContent(props) {
+ const {
+ seriesTitles,
+ onModalClose,
+ onOrganizeSeriesPress
+ } = props;
+
+ return (
+
+
+ Organize Selected Series
+
+
+
+
+ Tip: To preview a rename... select "Cancel" then any series title and use the
+
+
+
+
+ Are you sure you want to organize all files in the {seriesTitles.length} selected series?
+
+
+
+ {
+ seriesTitles.map((title) => {
+ return (
+
+ {title}
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Organize
+
+
+
+ );
+}
+
+OrganizeSeriesModalContent.propTypes = {
+ seriesTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onOrganizeSeriesPress: PropTypes.func.isRequired
+};
+
+export default OrganizeSeriesModalContent;
diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js
new file mode 100644
index 000000000..dabd1c58a
--- /dev/null
+++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js
@@ -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 createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import OrganizeSeriesModalContent from './OrganizeSeriesModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seriesIds }) => seriesIds,
+ createAllSeriesSelector(),
+ (seriesIds, allSeries) => {
+ const series = _.intersectionWith(allSeries, seriesIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedSeries = _.orderBy(series, 'sortTitle');
+ const seriesTitles = _.map(sortedSeries, 'title');
+
+ return {
+ seriesTitles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class OrganizeSeriesModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onOrganizeSeriesPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RENAME_SERIES,
+ seriesIds: this.props.seriesIds
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render(props) {
+ return (
+
+ );
+ }
+}
+
+OrganizeSeriesModalContentConnector.propTypes = {
+ seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeSeriesModalContentConnector);
diff --git a/frontend/src/Series/Editor/SeriesEditor.js b/frontend/src/Series/Editor/SeriesEditor.js
new file mode 100644
index 000000000..511ed8070
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditor.js
@@ -0,0 +1,289 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import NoSeries from 'Series/NoSeries';
+import OrganizeSeriesModal from './Organize/OrganizeSeriesModal';
+import SeriesEditorRowConnector from './SeriesEditorRowConnector';
+import SeriesEditorFooter from './SeriesEditorFooter';
+import SeriesEditorFilterModalConnector from './SeriesEditorFilterModalConnector';
+
+function getColumns(showLanguageProfile) {
+ return [
+ {
+ name: 'status',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'sortTitle',
+ label: 'Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'languageProfileId',
+ label: 'Language Profile',
+ isSortable: true,
+ isVisible: showLanguageProfile
+ },
+ {
+ name: 'seriesType',
+ label: 'Series Type',
+ isSortable: false,
+ isVisible: true
+ },
+ {
+ name: 'seasonFolder',
+ label: 'Season Folder',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ isSortable: false,
+ isVisible: true
+ }
+ ];
+}
+
+class SeriesEditor extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isOrganizingSeriesModalOpen: false,
+ columns: getColumns(props.showLanguageProfile)
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDeleting,
+ deleteError
+ } = this.props;
+
+ const hasFinishedDeleting = prevProps.isDeleting &&
+ !isDeleting &&
+ !deleteError;
+
+ if (hasFinishedDeleting) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSaveSelected = (changes) => {
+ this.props.onSaveSelected({
+ seriesIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ onOrganizeSeriesPress = () => {
+ this.setState({ isOrganizingSeriesModalOpen: true });
+ }
+
+ onOrganizeSeriesModalClose = (organized) => {
+ this.setState({ isOrganizingSeriesModalOpen: false });
+
+ if (organized === true) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ totalItems,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ isDeleting,
+ deleteError,
+ isOrganizingSeries,
+ showLanguageProfile,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ columns
+ } = this.state;
+
+ const selectedSeriesIds = this.getSelectedIds();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesEditor.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalItems: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ isOrganizingSeries: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSaveSelected: PropTypes.func.isRequired
+};
+
+export default SeriesEditor;
diff --git a/frontend/src/Series/Editor/SeriesEditorConnector.js b/frontend/src/Series/Editor/SeriesEditorConnector.js
new file mode 100644
index 000000000..fb9cb478f
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { setSeriesEditorSort, setSeriesEditorFilter, saveSeriesEditor } from 'Store/Actions/seriesEditorActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import SeriesEditor from './SeriesEditor';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.languageProfiles,
+ createClientSideCollectionSelector('series', 'seriesEditor'),
+ createCommandExecutingSelector(commandNames.RENAME_SERIES),
+ (languageProfiles, series, isOrganizingSeries) => {
+ return {
+ isOrganizingSeries,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ ...series
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetSeriesEditorSort: setSeriesEditorSort,
+ dispatchSetSeriesEditorFilter: setSeriesEditorFilter,
+ dispatchSaveSeriesEditor: saveSeriesEditor,
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchExecuteCommand: executeCommand
+};
+
+class SeriesEditorConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.dispatchSetSeriesEditorSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.dispatchSetSeriesEditorFilter({ selectedFilterKey });
+ }
+
+ onSaveSelected = (payload) => {
+ this.props.dispatchSaveSeriesEditor(payload);
+ }
+
+ onMoveSelected = (payload) => {
+ this.props.dispatchExecuteCommand({
+ name: commandNames.MOVE_SERIES,
+ ...payload
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesEditorConnector.propTypes = {
+ dispatchSetSeriesEditorSort: PropTypes.func.isRequired,
+ dispatchSetSeriesEditorFilter: PropTypes.func.isRequired,
+ dispatchSaveSeriesEditor: PropTypes.func.isRequired,
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchExecuteCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesEditorConnector);
diff --git a/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js b/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js
new file mode 100644
index 000000000..07a5230b2
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setSeriesEditorFilter } from 'Store/Actions/seriesEditorActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.series.items,
+ (state) => state.seriesEditor.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'seriesEditor'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setSeriesEditorFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/Series/Editor/SeriesEditorFooter.css b/frontend/src/Series/Editor/SeriesEditorFooter.css
new file mode 100644
index 000000000..5b509936b
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorFooter.css
@@ -0,0 +1,57 @@
+.inputContainer {
+ margin-right: 20px;
+ min-width: 150px;
+}
+
+.buttonContainer {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+}
+
+.buttonContainerContent {
+ flex-grow: 0;
+}
+
+.buttons {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+}
+
+.organizeSelectedButton,
+.tagsButton {
+ composes: button from 'Components/Link/SpinnerButton.css';
+
+ margin-right: 10px;
+ height: 35px;
+}
+
+.deleteSelectedButton {
+ composes: button from 'Components/Link/SpinnerButton.css';
+
+ margin-left: 50px;
+ height: 35px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .inputContainer {
+ margin-right: 0;
+ }
+
+ .buttonContainer {
+ justify-content: flex-start;
+ }
+
+ .buttonContainerContent {
+ flex-grow: 1;
+ }
+
+ .buttons {
+ justify-content: space-between;
+ }
+
+ .selectedSeriesLabel {
+ text-align: left;
+ }
+}
diff --git a/frontend/src/Series/Editor/SeriesEditorFooter.js b/frontend/src/Series/Editor/SeriesEditorFooter.js
new file mode 100644
index 000000000..b4edc9a83
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorFooter.js
@@ -0,0 +1,353 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import SelectInput from 'Components/Form/SelectInput';
+import LanguageProfileSelectInputConnector from 'Components/Form/LanguageProfileSelectInputConnector';
+import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector';
+import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector';
+import SeriesTypeSelectInput from 'Components/Form/SeriesTypeSelectInput';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
+import TagsModal from './Tags/TagsModal';
+import DeleteSeriesModal from './Delete/DeleteSeriesModal';
+import SeriesEditorFooterLabel from './SeriesEditorFooterLabel';
+import styles from './SeriesEditorFooter.css';
+
+const NO_CHANGE = 'noChange';
+
+class SeriesEditorFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ monitored: NO_CHANGE,
+ qualityProfileId: NO_CHANGE,
+ languageProfileId: NO_CHANGE,
+ seriesType: NO_CHANGE,
+ seasonFolder: NO_CHANGE,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false,
+ isDeleteSeriesModalOpen: false,
+ isTagsModalOpen: false,
+ isConfirmMoveModalOpen: false,
+ destinationRootFolder: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.setState({
+ monitored: NO_CHANGE,
+ qualityProfileId: NO_CHANGE,
+ languageProfileId: NO_CHANGE,
+ seriesType: NO_CHANGE,
+ seasonFolder: NO_CHANGE,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+
+ if (value === NO_CHANGE) {
+ return;
+ }
+
+ switch (name) {
+ case 'rootFolderPath':
+ this.setState({
+ isConfirmMoveModalOpen: true,
+ destinationRootFolder: value
+ });
+ break;
+ case 'monitored':
+ this.props.onSaveSelected({ [name]: value === 'monitored' });
+ break;
+ case 'seasonFolder':
+ this.props.onSaveSelected({ [name]: value === 'yes' });
+ break;
+ default:
+ this.props.onSaveSelected({ [name]: value });
+ }
+ }
+
+ onApplyTagsPress = (tags, applyTags) => {
+ this.setState({
+ savingTags: true,
+ isTagsModalOpen: false
+ });
+
+ this.props.onSaveSelected({
+ tags,
+ applyTags
+ });
+ }
+
+ onDeleteSelectedPress = () => {
+ this.setState({ isDeleteSeriesModalOpen: true });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteSeriesModalOpen: false });
+ }
+
+ onTagsPress = () => {
+ this.setState({ isTagsModalOpen: true });
+ }
+
+ onTagsModalClose = () => {
+ this.setState({ isTagsModalOpen: false });
+ }
+
+ onSaveRootFolderPress = () => {
+ this.setState({
+ isConfirmMoveModalOpen: false,
+ destinationRootFolder: null
+ });
+
+ this.props.onSaveSelected({ rootFolderPath: this.state.destinationRootFolder });
+ }
+
+ onMoveSeriesPress = () => {
+ this.setState({
+ isConfirmMoveModalOpen: false,
+ destinationRootFolder: null
+ });
+
+ this.props.onSaveSelected({
+ rootFolderPath: this.state.destinationRootFolder,
+ moveFiles: true
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ seriesIds,
+ selectedCount,
+ isSaving,
+ isDeleting,
+ isOrganizingSeries,
+ showLanguageProfile,
+ onOrganizeSeriesPress
+ } = this.props;
+
+ const {
+ monitored,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder,
+ rootFolderPath,
+ savingTags,
+ isTagsModalOpen,
+ isDeleteSeriesModalOpen,
+ isConfirmMoveModalOpen,
+ destinationRootFolder
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored' },
+ { key: 'unmonitored', value: 'Unmonitored' }
+ ];
+
+ const seasonFolderOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'yes', value: 'Yes' },
+ { key: 'no', value: 'No' }
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ showLanguageProfile &&
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rename Files
+
+
+
+ Set Tags
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesEditorFooter.propTypes = {
+ seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ selectedCount: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ isOrganizingSeries: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onSaveSelected: PropTypes.func.isRequired,
+ onOrganizeSeriesPress: PropTypes.func.isRequired
+};
+
+export default SeriesEditorFooter;
diff --git a/frontend/src/Series/Editor/SeriesEditorFooterLabel.css b/frontend/src/Series/Editor/SeriesEditorFooterLabel.css
new file mode 100644
index 000000000..9b4b40be6
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorFooterLabel.css
@@ -0,0 +1,8 @@
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.savingIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Series/Editor/SeriesEditorFooterLabel.js b/frontend/src/Series/Editor/SeriesEditorFooterLabel.js
new file mode 100644
index 000000000..fc77ece44
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorFooterLabel.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import SpinnerIcon from 'Components/SpinnerIcon';
+import styles from './SeriesEditorFooterLabel.css';
+
+function SeriesEditorFooterLabel(props) {
+ const {
+ className,
+ label,
+ isSaving
+ } = props;
+
+ return (
+
+ {label}
+
+ {
+ isSaving &&
+
+ }
+
+ );
+}
+
+SeriesEditorFooterLabel.propTypes = {
+ className: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isSaving: PropTypes.bool.isRequired
+};
+
+SeriesEditorFooterLabel.defaultProps = {
+ className: styles.label
+};
+
+export default SeriesEditorFooterLabel;
diff --git a/frontend/src/Series/Editor/SeriesEditorRow.css b/frontend/src/Series/Editor/SeriesEditorRow.css
new file mode 100644
index 000000000..d53a30f6d
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorRow.css
@@ -0,0 +1,5 @@
+.seasonFolder {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/Series/Editor/SeriesEditorRow.js b/frontend/src/Series/Editor/SeriesEditorRow.js
new file mode 100644
index 000000000..2d9330c2b
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorRow.js
@@ -0,0 +1,124 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import TagListConnector from 'Components/TagListConnector';
+import CheckInput from 'Components/Form/CheckInput';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import SeriesStatusCell from 'Series/Index/Table/SeriesStatusCell';
+import styles from './SeriesEditorRow.css';
+
+class SeriesEditorRow extends Component {
+
+ //
+ // Listeners
+
+ onSeasonFolderChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ status,
+ titleSlug,
+ title,
+ monitored,
+ languageProfile,
+ qualityProfile,
+ seriesType,
+ seasonFolder,
+ path,
+ tags,
+ columns,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {qualityProfile.name}
+
+
+ {
+ _.find(columns, { name: 'languageProfileId' }).isVisible &&
+
+ {languageProfile.name}
+
+ }
+
+
+ {titleCase(seriesType)}
+
+
+
+
+
+
+
+ {path}
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ status: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ languageProfile: PropTypes.object.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ seriesType: PropTypes.string.isRequired,
+ seasonFolder: PropTypes.bool.isRequired,
+ path: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+SeriesEditorRow.defaultProps = {
+ tags: []
+};
+
+export default SeriesEditorRow;
diff --git a/frontend/src/Series/Editor/SeriesEditorRowConnector.js b/frontend/src/Series/Editor/SeriesEditorRowConnector.js
new file mode 100644
index 000000000..3d1ee2e71
--- /dev/null
+++ b/frontend/src/Series/Editor/SeriesEditorRowConnector.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+import SeriesEditorRow from './SeriesEditorRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createLanguageProfileSelector(),
+ createQualityProfileSelector(),
+ (languageProfile, qualityProfile) => {
+ return {
+ languageProfile,
+ qualityProfile
+ };
+ }
+ );
+}
+
+function SeriesEditorRowConnector(props) {
+ return (
+
+ );
+}
+
+SeriesEditorRowConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps)(SeriesEditorRowConnector);
diff --git a/frontend/src/Series/Editor/Tags/TagsModal.js b/frontend/src/Series/Editor/Tags/TagsModal.js
new file mode 100644
index 000000000..0f6c2d7ec
--- /dev/null
+++ b/frontend/src/Series/Editor/Tags/TagsModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import TagsModalContentConnector from './TagsModalContentConnector';
+
+function TagsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+TagsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TagsModal;
diff --git a/frontend/src/Series/Editor/Tags/TagsModalContent.css b/frontend/src/Series/Editor/Tags/TagsModalContent.css
new file mode 100644
index 000000000..63be9aadd
--- /dev/null
+++ b/frontend/src/Series/Editor/Tags/TagsModalContent.css
@@ -0,0 +1,12 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.result {
+ padding-top: 4px;
+}
diff --git a/frontend/src/Series/Editor/Tags/TagsModalContent.js b/frontend/src/Series/Editor/Tags/TagsModalContent.js
new file mode 100644
index 000000000..ccc1120db
--- /dev/null
+++ b/frontend/src/Series/Editor/Tags/TagsModalContent.js
@@ -0,0 +1,187 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+import Button from 'Components/Link/Button';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './TagsModalContent.css';
+
+class TagsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ tags: [],
+ applyTags: 'add'
+ };
+ }
+
+ //
+ // Lifecycle
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ }
+
+ onApplyTagsPress = () => {
+ const {
+ tags,
+ applyTags
+ } = this.state;
+
+ this.props.onApplyTagsPress(tags, applyTags);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ seriesTags,
+ tagList,
+ onModalClose
+ } = this.props;
+
+ const {
+ tags,
+ applyTags
+ } = this.state;
+
+ const applyTagsOptions = [
+ { key: 'add', value: 'Add' },
+ { key: 'remove', value: 'Remove' },
+ { key: 'replace', value: 'Replace' }
+ ];
+
+ return (
+
+
+ Tags
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Apply
+
+
+
+ );
+ }
+}
+
+TagsModalContent.propTypes = {
+ seriesTags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onApplyTagsPress: PropTypes.func.isRequired
+};
+
+export default TagsModalContent;
diff --git a/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js
new file mode 100644
index 000000000..9d4907971
--- /dev/null
+++ b/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagsModalContent from './TagsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seriesIds }) => seriesIds,
+ createAllSeriesSelector(),
+ createTagsSelector(),
+ (seriesIds, allSeries, tagList) => {
+ const series = _.intersectionWith(allSeries, seriesIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const seriesTags = _.uniq(_.concat(..._.map(series, 'tags')));
+
+ return {
+ seriesTags,
+ tagList
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onAction() {
+ // Do something
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent);
diff --git a/frontend/src/Series/History/SeriesHistoryModal.js b/frontend/src/Series/History/SeriesHistoryModal.js
new file mode 100644
index 000000000..2d46c5f6f
--- /dev/null
+++ b/frontend/src/Series/History/SeriesHistoryModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import SeriesHistoryModalContentConnector from './SeriesHistoryModalContentConnector';
+
+function SeriesHistoryModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+SeriesHistoryModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeriesHistoryModal;
diff --git a/frontend/src/Series/History/SeriesHistoryModalContent.js b/frontend/src/Series/History/SeriesHistoryModalContent.js
new file mode 100644
index 000000000..ec593cd8c
--- /dev/null
+++ b/frontend/src/Series/History/SeriesHistoryModalContent.js
@@ -0,0 +1,137 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 SeasonNumber from 'Season/SeasonNumber';
+import SeriesHistoryRowConnector from './SeriesHistoryRowConnector';
+const columns = [
+ {
+ name: 'eventType',
+ isVisible: true
+ },
+ {
+ name: 'episode',
+ label: 'Episode',
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isVisible: true
+ },
+ {
+ name: 'details',
+ label: 'Details',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: 'Actions',
+ isVisible: true
+ }
+];
+
+class SeriesHistoryModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ seasonNumber,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onMarkAsFailedPress,
+ onModalClose
+ } = this.props;
+
+ const fullSeries = seasonNumber == null;
+ const hasItems = !!items.length;
+
+ return (
+
+
+ History {seasonNumber != null && }
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load history.
+ }
+
+ {
+ isPopulated && !hasItems && !error &&
+ No history.
+ }
+
+ {
+ isPopulated && hasItems && !error &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+SeriesHistoryModalContent.propTypes = {
+ seasonNumber: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeriesHistoryModalContent;
diff --git a/frontend/src/Series/History/SeriesHistoryModalContentConnector.js b/frontend/src/Series/History/SeriesHistoryModalContentConnector.js
new file mode 100644
index 000000000..8c8d4e5b2
--- /dev/null
+++ b/frontend/src/Series/History/SeriesHistoryModalContentConnector.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchSeriesHistory, clearSeriesHistory, seriesHistoryMarkAsFailed } from 'Store/Actions/seriesHistoryActions';
+import SeriesHistoryModalContent from './SeriesHistoryModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesHistory,
+ (seriesHistory) => {
+ return seriesHistory;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchSeriesHistory,
+ clearSeriesHistory,
+ seriesHistoryMarkAsFailed
+};
+
+class SeriesHistoryModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.fetchSeriesHistory({
+ seriesId,
+ seasonNumber
+ });
+ }
+
+ componentWillUnmount() {
+ this.props.clearSeriesHistory();
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = (historyId) => {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.seriesHistoryMarkAsFailed({
+ historyId,
+ seriesId,
+ seasonNumber
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesHistoryModalContentConnector.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number,
+ fetchSeriesHistory: PropTypes.func.isRequired,
+ clearSeriesHistory: PropTypes.func.isRequired,
+ seriesHistoryMarkAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesHistoryModalContentConnector);
diff --git a/frontend/src/Series/History/SeriesHistoryRow.css b/frontend/src/Series/History/SeriesHistoryRow.css
new file mode 100644
index 000000000..8c3fb8272
--- /dev/null
+++ b/frontend/src/Series/History/SeriesHistoryRow.css
@@ -0,0 +1,6 @@
+.details,
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 65px;
+}
diff --git a/frontend/src/Series/History/SeriesHistoryRow.js b/frontend/src/Series/History/SeriesHistoryRow.js
new file mode 100644
index 000000000..ecf176091
--- /dev/null
+++ b/frontend/src/Series/History/SeriesHistoryRow.js
@@ -0,0 +1,186 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import EpisodeNumber from 'Episode/EpisodeNumber';
+import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
+import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
+import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
+import styles from './SeriesHistoryRow.css';
+
+function getTitle(eventType) {
+ switch (eventType) {
+ case 'grabbed': return 'Grabbed';
+ case 'seriesFolderImported': return 'Series Folder Imported';
+ case 'downloadFolderImported': return 'Download Folder Imported';
+ case 'downloadFailed': return 'Download Failed';
+ case 'episodeFileDeleted': return 'Episode File Deleted';
+ case 'episodeFileRenamed': return 'Episode File Renamed';
+ default: return 'Unknown';
+ }
+}
+
+class SeriesHistoryRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMarkAsFailedModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = () => {
+ this.setState({ isMarkAsFailedModalOpen: true });
+ }
+
+ onConfirmMarkAsFailed = () => {
+ this.props.onMarkAsFailedPress(this.props.id);
+ this.setState({ isMarkAsFailedModalOpen: false });
+ }
+
+ onMarkAsFailedModalClose = () => {
+ this.setState({ isMarkAsFailedModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ eventType,
+ sourceTitle,
+ language,
+ languageCutoffNotMet,
+ quality,
+ qualityCutoffNotMet,
+ date,
+ data,
+ fullSeries,
+ series,
+ episode
+ } = this.props;
+
+ const {
+ isMarkAsFailedModalOpen
+ } = this.state;
+
+ const EpisodeComponent = fullSeries ? SeasonEpisodeNumber : EpisodeNumber;
+
+ return (
+
+
+
+
+
+
+
+
+ {sourceTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ title={getTitle(eventType)}
+ body={
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+SeriesHistoryRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ language: PropTypes.object.isRequired,
+ languageCutoffNotMet: PropTypes.bool.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ fullSeries: PropTypes.bool.isRequired,
+ series: PropTypes.object.isRequired,
+ episode: PropTypes.object.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+export default SeriesHistoryRow;
diff --git a/frontend/src/Series/History/SeriesHistoryRowConnector.js b/frontend/src/Series/History/SeriesHistoryRowConnector.js
new file mode 100644
index 000000000..840e522f0
--- /dev/null
+++ b/frontend/src/Series/History/SeriesHistoryRowConnector.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import SeriesHistoryRow from './SeriesHistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ createEpisodeSelector(),
+ (series, episode) => {
+ return {
+ series,
+ episode
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHistory,
+ markAsFailed
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesHistoryRow);
diff --git a/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js
new file mode 100644
index 000000000..29f2a15c8
--- /dev/null
+++ b/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import SeriesIndexFilterModalConnector from 'Series/Index/SeriesIndexFilterModalConnector';
+
+function SeriesIndexFilterMenu(props) {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ isDisabled,
+ onFilterSelect
+ } = props;
+
+ return (
+
+ );
+}
+
+SeriesIndexFilterMenu.propTypes = {
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+SeriesIndexFilterMenu.defaultProps = {
+ showCustomFilters: false
+};
+
+export default SeriesIndexFilterMenu;
diff --git a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js
new file mode 100644
index 000000000..83aabf604
--- /dev/null
+++ b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js
@@ -0,0 +1,159 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, sortDirections } from 'Helpers/Props';
+import SortMenu from 'Components/Menu/SortMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import SortMenuItem from 'Components/Menu/SortMenuItem';
+
+function SeriesIndexSortMenu(props) {
+ const {
+ sortKey,
+ sortDirection,
+ isDisabled,
+ onSortSelect
+ } = props;
+
+ return (
+
+
+
+ Monitored/Status
+
+
+
+ Title
+
+
+
+ Network
+
+
+
+ Quality Profile
+
+
+
+ Language Profile
+
+
+
+ Next Airing
+
+
+
+ Previous Airing
+
+
+
+ Added
+
+
+
+ Seasons
+
+
+
+ Episodes
+
+
+
+ Episode Count
+
+
+
+ Latest Season
+
+
+
+ Path
+
+
+
+ Size on Disk
+
+
+
+ );
+}
+
+SeriesIndexSortMenu.propTypes = {
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ isDisabled: PropTypes.bool.isRequired,
+ onSortSelect: PropTypes.func.isRequired
+};
+
+export default SeriesIndexSortMenu;
diff --git a/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js
new file mode 100644
index 000000000..2fa6f6f98
--- /dev/null
+++ b/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import ViewMenu from 'Components/Menu/ViewMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import ViewMenuItem from 'Components/Menu/ViewMenuItem';
+
+function SeriesIndexViewMenu(props) {
+ const {
+ view,
+ isDisabled,
+ onViewSelect
+ } = props;
+
+ return (
+
+
+
+ Table
+
+
+
+ Posters
+
+
+
+ Overview
+
+
+
+ );
+}
+
+SeriesIndexViewMenu.propTypes = {
+ view: PropTypes.string.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onViewSelect: PropTypes.func.isRequired
+};
+
+export default SeriesIndexViewMenu;
diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js
new file mode 100644
index 000000000..0b7a28ba0
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import SeriesIndexOverviewOptionsModalContentConnector from './SeriesIndexOverviewOptionsModalContentConnector';
+
+function SeriesIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+SeriesIndexOverviewOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeriesIndexOverviewOptionsModal;
diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js
new file mode 100644
index 000000000..3cc19475c
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js
@@ -0,0 +1,305 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+const posterSizeOptions = [
+ { key: 'small', value: 'Small' },
+ { key: 'medium', value: 'Medium' },
+ { key: 'large', value: 'Large' }
+];
+
+class SeriesIndexOverviewOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showMonitored: props.showMonitored,
+ showNetwork: props.showNetwork,
+ showQualityProfile: props.showQualityProfile,
+ showPreviousAiring: props.showPreviousAiring,
+ showAdded: props.showAdded,
+ showSeasonCount: props.showSeasonCount,
+ showPath: props.showPath,
+ showSizeOnDisk: props.showSizeOnDisk,
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showMonitored,
+ showNetwork,
+ showQualityProfile,
+ showPreviousAiring,
+ showAdded,
+ showSeasonCount,
+ showPath,
+ showSizeOnDisk,
+ showSearchAction
+ } = this.props;
+
+ const state = {};
+
+ if (detailedProgressBar !== prevProps.detailedProgressBar) {
+ state.detailedProgressBar = detailedProgressBar;
+ }
+
+ if (size !== prevProps.size) {
+ state.size = size;
+ }
+
+ if (showMonitored !== prevProps.showMonitored) {
+ state.showMonitored = showMonitored;
+ }
+
+ if (showNetwork !== prevProps.showNetwork) {
+ state.showNetwork = showNetwork;
+ }
+
+ if (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (showPreviousAiring !== prevProps.showPreviousAiring) {
+ state.showPreviousAiring = showPreviousAiring;
+ }
+
+ if (showAdded !== prevProps.showAdded) {
+ state.showAdded = showAdded;
+ }
+
+ if (showSeasonCount !== prevProps.showSeasonCount) {
+ state.showSeasonCount = showSeasonCount;
+ }
+
+ if (showPath !== prevProps.showPath) {
+ state.showPath = showPath;
+ }
+
+ if (showSizeOnDisk !== prevProps.showSizeOnDisk) {
+ state.showSizeOnDisk = showSizeOnDisk;
+ }
+
+ if (showSearchAction !== prevProps.showSearchAction) {
+ state.showSearchAction = showSearchAction;
+ }
+
+ if (!_.isEmpty(state)) {
+ this.setState(state);
+ }
+ }
+
+ //
+ // Listeners
+
+ onChangeOverviewOption = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onChangeOverviewOption({ [name]: value });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ detailedProgressBar,
+ size,
+ showMonitored,
+ showNetwork,
+ showQualityProfile,
+ showPreviousAiring,
+ showAdded,
+ showSeasonCount,
+ showPath,
+ showSizeOnDisk,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Overview Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+SeriesIndexOverviewOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showNetwork: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showPreviousAiring: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showSeasonCount: PropTypes.bool.isRequired,
+ showPath: PropTypes.bool.isRequired,
+ showSizeOnDisk: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ onChangeOverviewOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeriesIndexOverviewOptionsModalContent;
diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js
new file mode 100644
index 000000000..5feffa506
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setSeriesOverviewOption } from 'Store/Actions/seriesIndexActions';
+import SeriesIndexOverviewOptionsModalContent from './SeriesIndexOverviewOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesIndex,
+ (seriesIndex) => {
+ return seriesIndex.overviewOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangeOverviewOption(payload) {
+ dispatch(setSeriesOverviewOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexOverviewOptionsModalContent);
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.css b/frontend/src/Series/Index/Overview/SeriesIndexOverview.css
new file mode 100644
index 000000000..6311d9be1
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.css
@@ -0,0 +1,96 @@
+$hoverScale: 1.05;
+
+.container {
+ &:hover {
+ .content {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+ }
+}
+
+.content {
+ display: flex;
+ flex-grow: 1;
+}
+
+.poster {
+ position: relative;
+}
+
+.posterContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ display: block;
+ color: $defaultColor;
+
+ &:hover {
+ color: $defaultColor;
+ text-decoration: none;
+ }
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.info {
+ display: flex;
+ flex: 1 0 1px;
+ flex-direction: column;
+ overflow: hidden;
+ padding-left: 10px;
+}
+
+.titleRow {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 auto;
+ margin-bottom: 10px;
+ line-height: 32px;
+}
+
+.title {
+ @add-mixin truncate;
+ composes: link;
+
+ flex: 1 0 1px;
+ font-weight: 300;
+ font-size: 30px;
+}
+
+.actions {
+ white-space: nowrap;
+}
+
+.details {
+ display: flex;
+ justify-content: space-between;
+ flex: 1 0 auto;
+}
+
+.overview {
+ composes: link;
+
+ flex: 0 1 1000px;
+ overflow: hidden;
+ min-height: 0;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .overview {
+ display: none;
+ }
+}
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.js b/frontend/src/Series/Index/Overview/SeriesIndexOverview.js
new file mode 100644
index 000000000..c89f76430
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.js
@@ -0,0 +1,280 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import { icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import fonts from 'Styles/Variables/fonts';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import SeriesPoster from 'Series/SeriesPoster';
+import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
+import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
+import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
+import SeriesIndexOverviewInfo from './SeriesIndexOverviewInfo';
+import styles from './SeriesIndexOverview.css';
+
+const columnPadding = parseInt(dimensions.seriesIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen);
+const defaultFontSize = parseInt(fonts.defaultFontSize);
+const lineHeight = parseFloat(fonts.lineHeight);
+
+// Hardcoded height beased on line-height of 32 + bottom margin of 10.
+// Less side-effecty than using react-measure.
+const titleRowHeight = 42;
+
+function getContentHeight(rowHeight, isSmallScreen) {
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+
+ return rowHeight - padding;
+}
+
+class SeriesIndexOverview extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditSeriesModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditSeriesModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteSeriesModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ title,
+ overview,
+ monitored,
+ status,
+ titleSlug,
+ nextAiring,
+ statistics,
+ images,
+ posterWidth,
+ posterHeight,
+ qualityProfile,
+ overviewOptions,
+ showSearchAction,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ rowHeight,
+ isSmallScreen,
+ isRefreshingSeries,
+ isSearchingSeries,
+ onRefreshSeriesPress,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ seasonCount,
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount
+ } = statistics;
+
+ const {
+ isEditSeriesModalOpen,
+ isDeleteSeriesModalOpen
+ } = this.state;
+
+ const link = `/series/${titleSlug}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ const contentHeight = getContentHeight(rowHeight, isSmallScreen);
+ const overviewHeight = contentHeight - titleRowHeight;
+
+ return (
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesIndexOverview.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ overview: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ rowHeight: PropTypes.number.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ overviewOptions: PropTypes.object.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ isSearchingSeries: PropTypes.bool.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired
+};
+
+SeriesIndexOverview.defaultProps = {
+ statistics: {
+ seasonCount: 0,
+ episodeCount: 0,
+ episodeFileCount: 0,
+ totalEpisodeCount: 0
+ }
+};
+
+export default SeriesIndexOverview;
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css
new file mode 100644
index 000000000..5dc53762f
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css
@@ -0,0 +1,12 @@
+.infos {
+ display: flex;
+ flex: 0 0 250px;
+ flex-direction: column;
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .infos {
+ margin-left: 0;
+ }
+}
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js
new file mode 100644
index 000000000..844219f04
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js
@@ -0,0 +1,266 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import SeriesIndexOverviewInfoRow from './SeriesIndexOverviewInfoRow';
+import styles from './SeriesIndexOverviewInfo.css';
+
+const infoRowHeight = parseInt(dimensions.seriesIndexOverviewInfoRowHeight);
+
+const rows = [
+ {
+ name: 'monitored',
+ showProp: 'showMonitored',
+ valueProp: 'monitored'
+
+ },
+ {
+ name: 'network',
+ showProp: 'showNetwork',
+ valueProp: 'network'
+ },
+ {
+ name: 'qualityProfileId',
+ showProp: 'showQualityProfile',
+ valueProp: 'qualityProfileId'
+ },
+ {
+ name: 'previousAiring',
+ showProp: 'showPreviousAiring',
+ valueProp: 'previousAiring'
+ },
+ {
+ name: 'added',
+ showProp: 'showAdded',
+ valueProp: 'added'
+ },
+ {
+ name: 'seasonCount',
+ showProp: 'showSeasonCount',
+ valueProp: 'seasonCount'
+ },
+ {
+ name: 'path',
+ showProp: 'showPath',
+ valueProp: 'path'
+ },
+ {
+ name: 'sizeOnDisk',
+ showProp: 'showSizeOnDisk',
+ valueProp: 'sizeOnDisk'
+ }
+];
+
+function isVisible(row, props) {
+ const {
+ name,
+ showProp,
+ valueProp
+ } = row;
+
+ if (props[valueProp] == null) {
+ return false;
+ }
+
+ return props[showProp] || props.sortKey === name;
+}
+
+function getInfoRowProps(row, props) {
+ const { name } = row;
+
+ if (name === 'monitored') {
+ const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored';
+
+ return {
+ title: monitoredText,
+ iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED,
+ label: monitoredText
+ };
+ }
+
+ if (name === 'network') {
+ return {
+ title: 'Network',
+ iconName: icons.NETWORK,
+ label: props.network
+ };
+ }
+
+ if (name === 'qualityProfileId') {
+ return {
+ title: 'Quality Profile',
+ iconName: icons.PROFILE,
+ label: props.qualityProfile.name
+ };
+ }
+
+ if (name === 'previousAiring') {
+ const {
+ previousAiring,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat
+ } = props;
+
+ return {
+ title: `Previous Airing: ${formatDateTime(previousAiring, longDateFormat, timeFormat)}`,
+ iconName: icons.CALENDAR,
+ label: getRelativeDate(
+ previousAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ };
+ }
+
+ if (name === 'added') {
+ const {
+ added,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat
+ } = props;
+
+ return {
+ title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`,
+ iconName: icons.ADD,
+ label: getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ };
+ }
+
+ if (name === 'seasonCount') {
+ const { seasonCount } = props;
+ let seasons = '1 season';
+
+ if (seasonCount === 0) {
+ seasons = 'No seasons';
+ } else if (seasonCount > 1) {
+ seasons = `${seasonCount} seasons`;
+ }
+
+ return {
+ title: 'Season Count',
+ iconName: icons.CIRCLE,
+ label: seasons
+ };
+ }
+
+ if (name === 'path') {
+ return {
+ title: 'Path',
+ iconName: icons.FOLDER,
+ label: props.path
+ };
+ }
+
+ if (name === 'sizeOnDisk') {
+ return {
+ title: 'Size on Disk',
+ iconName: icons.DRIVE,
+ label: formatBytes(props.sizeOnDisk)
+ };
+ }
+}
+
+function SeriesIndexOverviewInfo(props) {
+ const {
+ height,
+ nextAiring,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat
+ } = props;
+
+ let shownRows = 1;
+ const maxRows = Math.floor(height / (infoRowHeight + 4));
+
+ return (
+
+ {
+ !!nextAiring &&
+
+ }
+
+ {
+ rows.map((row) => {
+ if (!isVisible(row, props)) {
+ return null;
+ }
+
+ if (shownRows >= maxRows) {
+ return null;
+ }
+
+ shownRows++;
+
+ const infoRowProps = getInfoRowProps(row, props);
+
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+SeriesIndexOverviewInfo.propTypes = {
+ height: PropTypes.number.isRequired,
+ showNetwork: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showPreviousAiring: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showSeasonCount: PropTypes.bool.isRequired,
+ showPath: PropTypes.bool.isRequired,
+ showSizeOnDisk: PropTypes.bool.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ nextAiring: PropTypes.string,
+ network: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ seasonCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default SeriesIndexOverviewInfo;
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css
new file mode 100644
index 000000000..bae40ed1f
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css
@@ -0,0 +1,10 @@
+.infoRow {
+ flex: 0 0 $seriesIndexOverviewInfoRowHeight;
+ margin: 2px 0;
+}
+
+.icon {
+ margin-right: 5px;
+ width: 25px !important;
+ text-align: center;
+}
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js
new file mode 100644
index 000000000..87c388869
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import styles from './SeriesIndexOverviewInfoRow.css';
+
+function SeriesIndexOverviewInfoRow(props) {
+ const {
+ title,
+ iconName,
+ label
+ } = props;
+
+ return (
+
+
+
+ {label}
+
+ );
+}
+
+SeriesIndexOverviewInfoRow.propTypes = {
+ title: PropTypes.string,
+ iconName: PropTypes.object.isRequired,
+ label: PropTypes.string.isRequired
+};
+
+export default SeriesIndexOverviewInfoRow;
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js
new file mode 100644
index 000000000..3d0ab8c20
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js
@@ -0,0 +1,289 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { Grid, WindowScroller } from 'react-virtualized';
+import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import Measure from 'Components/Measure';
+import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
+import SeriesIndexOverview from './SeriesIndexOverview';
+import styles from './SeriesIndexOverviews.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.seriesIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+function calculatePosterWidth(posterSize, isSmallScreen) {
+ const maxiumPosterWidth = isSmallScreen ? 152 : 162;
+
+ if (posterSize === 'large') {
+ return maxiumPosterWidth;
+ }
+
+ if (posterSize === 'medium') {
+ return Math.floor(maxiumPosterWidth * 0.75);
+ }
+
+ return Math.floor(maxiumPosterWidth * 0.5);
+}
+
+function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) {
+ const {
+ detailedProgressBar
+ } = overviewOptions;
+
+ const heights = [
+ posterHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return Math.ceil((250 / 170) * posterWidth);
+}
+
+class SeriesIndexOverviews extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnCount: 1,
+ posterWidth: 162,
+ posterHeight: 238,
+ rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filters,
+ sortKey,
+ sortDirection,
+ overviewOptions,
+ jumpToCharacter
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+ const overviewOptionsChanged = !_.isMatch(prevProps.overviewOptions, overviewOptions);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.overviewOptions !== overviewOptions ||
+ itemsChanged
+ ) {
+ this.calculateGrid();
+ }
+
+ if (
+ prevProps.filters !== filters ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged ||
+ overviewOptionsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+
+ if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
+ const index = getIndexOfFirstCharacter(items, jumpToCharacter);
+
+ if (index != null) {
+ const {
+ rowHeight
+ } = this.state;
+
+ const scrollTop = rowHeight * index;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+ const {
+ rowHeight
+ } = this.state;
+
+ const index = getIndexOfFirstCharacter(items, character);
+
+ if (index != null) {
+ const scrollTop = rowHeight * index;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+
+ setGridRef = (ref) => {
+ this._grid = ref;
+ }
+
+ calculateGrid = (width = this.state.width, isSmallScreen) => {
+ const {
+ sortKey,
+ overviewOptions
+ } = this.props;
+
+ const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen);
+ const posterHeight = calculatePosterHeight(posterWidth);
+ const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions);
+
+ this.setState({
+ width,
+ posterWidth,
+ posterHeight,
+ rowHeight
+ });
+ }
+
+ cellRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ sortKey,
+ overviewOptions,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ isSmallScreen
+ } = this.props;
+
+ const {
+ posterWidth,
+ posterHeight,
+ rowHeight
+ } = this.state;
+
+ const series = items[rowIndex];
+
+ if (!series) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.calculateGrid(width, this.props.isSmallScreen);
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ scrollTop,
+ isSmallScreen,
+ onScroll
+ } = this.props;
+
+ const {
+ width,
+ rowHeight
+ } = this.state;
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+SeriesIndexOverviews.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ overviewOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ jumpToCharacter: PropTypes.string,
+ contentBody: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default SeriesIndexOverviews;
diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js
new file mode 100644
index 000000000..cf7025984
--- /dev/null
+++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import SeriesIndexOverviews from './SeriesIndexOverviews';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesIndex.overviewOptions,
+ createClientSideCollectionSelector('series', 'seriesIndex'),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (overviewOptions, series, uiSettings, dimensions) => {
+ return {
+ overviewOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...series
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(SeriesIndexOverviews);
diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js
new file mode 100644
index 000000000..8b1d79dcb
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import SeriesIndexPosterOptionsModalContentConnector from './SeriesIndexPosterOptionsModalContentConnector';
+
+function SeriesIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+SeriesIndexPosterOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeriesIndexPosterOptionsModal;
diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js
new file mode 100644
index 000000000..b7f66401e
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js
@@ -0,0 +1,213 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+const posterSizeOptions = [
+ { key: 'small', value: 'Small' },
+ { key: 'medium', value: 'Medium' },
+ { key: 'large', value: 'Large' }
+];
+
+class SeriesIndexPosterOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showTitle: props.showTitle,
+ showMonitored: props.showMonitored,
+ showQualityProfile: props.showQualityProfile,
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ showSearchAction
+ } = this.props;
+
+ const state = {};
+
+ if (detailedProgressBar !== prevProps.detailedProgressBar) {
+ state.detailedProgressBar = detailedProgressBar;
+ }
+
+ if (size !== prevProps.size) {
+ state.size = size;
+ }
+
+ if (showTitle !== prevProps.showTitle) {
+ state.showTitle = showTitle;
+ }
+
+ if (showMonitored !== prevProps.showMonitored) {
+ state.showMonitored = showMonitored;
+ }
+
+ if (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (showSearchAction !== prevProps.showSearchAction) {
+ state.showSearchAction = showSearchAction;
+ }
+
+ if (!_.isEmpty(state)) {
+ this.setState(state);
+ }
+ }
+
+ //
+ // Listeners
+
+ onChangePosterOption = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onChangePosterOption({ [name]: value });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Poster Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+SeriesIndexPosterOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ onChangePosterOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeriesIndexPosterOptionsModalContent;
diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js
new file mode 100644
index 000000000..bb15208d7
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setSeriesPosterOption } from 'Store/Actions/seriesIndexActions';
+import SeriesIndexPosterOptionsModalContent from './SeriesIndexPosterOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesIndex,
+ (seriesIndex) => {
+ return seriesIndex.posterOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangePosterOption(payload) {
+ dispatch(setSeriesPosterOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexPosterOptionsModalContent);
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.css b/frontend/src/Series/Index/Posters/SeriesIndexPoster.css
new file mode 100644
index 000000000..227784df1
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.css
@@ -0,0 +1,102 @@
+$hoverScale: 1.05;
+
+.container {
+ padding: 10px;
+}
+
+.content {
+ transition: all 200ms ease-in;
+
+ &:hover {
+ z-index: 2;
+ box-shadow: 0 0 12px $black;
+ transition: all 200ms ease-in;
+
+ .controls {
+ opacity: 0.9;
+ transition: opacity 200ms linear 150ms;
+ }
+ }
+}
+
+.posterContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ position: relative;
+ display: block;
+ height: 70px;
+ background-color: $defaultColor;
+}
+
+.overlayTitle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 5px;
+ width: 100%;
+ height: 100%;
+ color: $offWhite;
+ text-align: center;
+ font-size: 20px;
+}
+
+.nextAiring {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.title {
+ @add-mixin truncate;
+
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.controls {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ z-index: 3;
+ border-radius: 4px;
+ background-color: #4f566f;
+ color: $white;
+ font-size: $smallFontSize;
+ opacity: 0;
+ transition: opacity 0;
+}
+
+.action {
+ composes: button from 'Components/Link/IconButton.css';
+
+ &:hover {
+ color: #ccc;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .container {
+ padding: 5px;
+ }
+}
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.js b/frontend/src/Series/Index/Posters/SeriesIndexPoster.js
new file mode 100644
index 000000000..4e4238cc7
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.js
@@ -0,0 +1,293 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import SeriesPoster from 'Series/SeriesPoster';
+import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
+import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
+import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
+import SeriesIndexPosterInfo from './SeriesIndexPosterInfo';
+import styles from './SeriesIndexPoster.css';
+
+class SeriesIndexPoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPosterError: false,
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditSeriesModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditSeriesModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteSeriesModalOpen: false });
+ }
+
+ onPosterLoad = () => {
+ if (this.state.hasPosterError) {
+ this.setState({ hasPosterError: false });
+ }
+ }
+
+ onPosterLoadError = () => {
+ if (!this.state.hasPosterError) {
+ this.setState({ hasPosterError: true });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ title,
+ monitored,
+ status,
+ titleSlug,
+ nextAiring,
+ statistics,
+ images,
+ posterWidth,
+ posterHeight,
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ qualityProfile,
+ showSearchAction,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isRefreshingSeries,
+ isSearchingSeries,
+ onRefreshSeriesPress,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ seasonCount,
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount
+ } = statistics;
+
+ const {
+ hasPosterError,
+ isEditSeriesModalOpen,
+ isDeleteSeriesModalOpen
+ } = this.state;
+
+ const link = `/series/${titleSlug}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ return (
+
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+ {
+ hasPosterError &&
+
+ {title}
+
+ }
+
+
+
+
+
+ {
+ showTitle &&
+
+ {title}
+
+ }
+
+ {
+ showMonitored &&
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+ }
+
+ {
+ showQualityProfile &&
+
+ {qualityProfile.name}
+
+ }
+
+ {
+ nextAiring &&
+
+ {
+ getRelativeDate(
+ nextAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesIndexPoster.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ isSearchingSeries: PropTypes.bool.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+SeriesIndexPoster.defaultProps = {
+ statistics: {
+ seasonCount: 0,
+ episodeCount: 0,
+ episodeFileCount: 0,
+ totalEpisodeCount: 0
+ }
+};
+
+export default SeriesIndexPoster;
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css
new file mode 100644
index 000000000..aab27d827
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css
@@ -0,0 +1,5 @@
+.info {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js
new file mode 100644
index 000000000..39ecdca52
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js
@@ -0,0 +1,125 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import styles from './SeriesIndexPosterInfo.css';
+
+function SeriesIndexPosterInfo(props) {
+ const {
+ network,
+ qualityProfile,
+ showQualityProfile,
+ previousAiring,
+ added,
+ seasonCount,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (sortKey === 'network' && network) {
+ return (
+
+ {network}
+
+ );
+ }
+
+ if (sortKey === 'qualityProfileId' && !showQualityProfile) {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (sortKey === 'previousAiring' && previousAiring) {
+ return (
+
+ {
+ getRelativeDate(
+ previousAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ );
+ }
+
+ if (sortKey === 'added' && added) {
+ const addedDate = getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: false
+ }
+ );
+
+ return (
+
+ {`Added ${addedDate}`}
+
+ );
+ }
+
+ if (sortKey === 'seasonCount') {
+ let seasons = '1 season';
+
+ if (seasonCount === 0) {
+ seasons = 'No seasons';
+ } else if (seasonCount > 1) {
+ seasons = `${seasonCount} seasons`;
+ }
+
+ return (
+
+ {seasons}
+
+ );
+ }
+
+ if (sortKey === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (sortKey === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ return null;
+}
+
+SeriesIndexPosterInfo.propTypes = {
+ network: PropTypes.string,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ seasonCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default SeriesIndexPosterInfo;
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosters.css b/frontend/src/Series/Index/Posters/SeriesIndexPosters.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/SeriesIndexPosters.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosters.js b/frontend/src/Series/Index/Posters/SeriesIndexPosters.js
new file mode 100644
index 000000000..986943a05
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/SeriesIndexPosters.js
@@ -0,0 +1,327 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { Grid, WindowScroller } from 'react-virtualized';
+import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import Measure from 'Components/Measure';
+import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
+import SeriesIndexPoster from './SeriesIndexPoster';
+import styles from './SeriesIndexPosters.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.seriesIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+const additionalColumnCount = {
+ small: 3,
+ medium: 2,
+ large: 1
+};
+
+function calculateColumnWidth(width, posterSize, isSmallScreen) {
+ const maxiumColumnWidth = isSmallScreen ? 172 : 182;
+ const columns = Math.floor(width / maxiumColumnWidth);
+ const remainder = width % maxiumColumnWidth;
+
+ if (remainder === 0 && posterSize === 'large') {
+ return maxiumColumnWidth;
+ }
+
+ return Math.floor(width / (columns + additionalColumnCount[posterSize]));
+}
+
+function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) {
+ const {
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile
+ } = posterOptions;
+
+ const nextAiringHeight = 19;
+
+ const heights = [
+ posterHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ nextAiringHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ if (showTitle) {
+ heights.push(19);
+ }
+
+ if (showMonitored) {
+ heights.push(19);
+ }
+
+ if (showQualityProfile) {
+ heights.push(19);
+ }
+
+ switch (sortKey) {
+ case 'network':
+ case 'seasons':
+ case 'previousAiring':
+ case 'added':
+ case 'path':
+ case 'sizeOnDisk':
+ heights.push(19);
+ break;
+ case 'qualityProfileId':
+ if (!showQualityProfile) {
+ heights.push(19);
+ }
+ break;
+ default:
+ // No need to add a height of 0
+ }
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return Math.ceil((250 / 170) * posterWidth);
+}
+
+class SeriesIndexPosters extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnWidth: 182,
+ columnCount: 1,
+ posterWidth: 162,
+ posterHeight: 238,
+ rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filters,
+ sortKey,
+ sortDirection,
+ posterOptions,
+ jumpToCharacter
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.posterOptions !== posterOptions ||
+ itemsChanged
+ ) {
+ this.calculateGrid();
+ }
+
+ if (
+ prevProps.filters !== filters ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+
+ if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
+ const index = getIndexOfFirstCharacter(items, jumpToCharacter);
+
+ if (index != null) {
+ const {
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const row = Math.floor(index / columnCount);
+ const scrollTop = rowHeight * row;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+ }
+
+ //
+ // Control
+
+ setGridRef = (ref) => {
+ this._grid = ref;
+ }
+
+ calculateGrid = (width = this.state.width, isSmallScreen) => {
+ const {
+ sortKey,
+ posterOptions
+ } = this.props;
+
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+ const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen);
+ const columnCount = Math.max(Math.floor(width / columnWidth), 1);
+ const posterWidth = columnWidth - padding;
+ const posterHeight = calculatePosterHeight(posterWidth);
+ const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions);
+
+ this.setState({
+ width,
+ columnWidth,
+ columnCount,
+ posterWidth,
+ posterHeight,
+ rowHeight
+ });
+ }
+
+ cellRenderer = ({ key, rowIndex, columnIndex, style }) => {
+ const {
+ items,
+ sortKey,
+ posterOptions,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = this.props;
+
+ const {
+ posterWidth,
+ posterHeight,
+ columnCount
+ } = this.state;
+
+ const {
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile
+ } = posterOptions;
+
+ const series = items[rowIndex * columnCount + columnIndex];
+
+ if (!series) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.calculateGrid(width, this.props.isSmallScreen);
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ scrollTop,
+ isSmallScreen,
+ onScroll
+ } = this.props;
+
+ const {
+ width,
+ columnWidth,
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const rowCount = Math.ceil(items.length / columnCount);
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+SeriesIndexPosters.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ posterOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ jumpToCharacter: PropTypes.string,
+ contentBody: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default SeriesIndexPosters;
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js b/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js
new file mode 100644
index 000000000..0d74d3256
--- /dev/null
+++ b/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import SeriesIndexPosters from './SeriesIndexPosters';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesIndex.posterOptions,
+ createClientSideCollectionSelector('series', 'seriesIndex'),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (posterOptions, series, uiSettings, dimensions) => {
+ return {
+ posterOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...series
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(SeriesIndexPosters);
diff --git a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css
new file mode 100644
index 000000000..dbf3499ab
--- /dev/null
+++ b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css
@@ -0,0 +1,14 @@
+.progress {
+ composes: container from 'Components/ProgressBar.css';
+
+ border-radius: 0;
+ background-color: #5b5b5b;
+ color: $white;
+ transition: width 200ms ease;
+}
+
+.progressBar {
+ composes: progressBar from 'Components/ProgressBar.css';
+
+ transition: width 200ms ease;
+}
diff --git a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js
new file mode 100644
index 000000000..882f4850a
--- /dev/null
+++ b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import { sizes } from 'Helpers/Props';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './SeriesIndexProgressBar.css';
+
+function SeriesIndexProgressBar(props) {
+ const {
+ monitored,
+ status,
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount,
+ posterWidth,
+ detailedProgressBar
+ } = props;
+
+ const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
+ const text = `${episodeFileCount} / ${episodeCount}`;
+
+ return (
+
+ );
+}
+
+SeriesIndexProgressBar.propTypes = {
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ episodeCount: PropTypes.number.isRequired,
+ episodeFileCount: PropTypes.number.isRequired,
+ totalEpisodeCount: PropTypes.number.isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired
+};
+
+export default SeriesIndexProgressBar;
diff --git a/frontend/src/Series/Index/SeriesIndex.css b/frontend/src/Series/Index/SeriesIndex.css
new file mode 100644
index 000000000..443372a73
--- /dev/null
+++ b/frontend/src/Series/Index/SeriesIndex.css
@@ -0,0 +1,51 @@
+.pageContentBodyWrapper {
+ display: flex;
+ flex: 1 0 1px;
+ overflow: hidden;
+}
+
+.contentBody {
+ composes: contentBody from 'Components/Page/PageContentBody.css';
+
+ display: flex;
+ flex-direction: column;
+}
+
+.postersInnerContentBody {
+ composes: innerContentBody from 'Components/Page/PageContentBody.css';
+
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ /* 5px less padding than normal to handle poster's 5px margin */
+ padding: calc($pageContentBodyPadding - 5px);
+}
+
+.tableInnerContentBody {
+ composes: innerContentBody from 'Components/Page/PageContentBody.css';
+
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.contentBodyContainer {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .pageContentBodyWrapper {
+ flex-basis: auto;
+ }
+
+ .contentBody {
+ flex-basis: 1px;
+ }
+
+ .postersInnerContentBody {
+ padding: calc($pageContentBodyPaddingSmallScreen - 5px);
+ }
+}
diff --git a/frontend/src/Series/Index/SeriesIndex.js b/frontend/src/Series/Index/SeriesIndex.js
new file mode 100644
index 000000000..f1eae53c6
--- /dev/null
+++ b/frontend/src/Series/Index/SeriesIndex.js
@@ -0,0 +1,369 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import { align, icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageJumpBar from 'Components/Page/PageJumpBar';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import NoSeries from 'Series/NoSeries';
+import SeriesIndexTableConnector from './Table/SeriesIndexTableConnector';
+import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal';
+import SeriesIndexPostersConnector from './Posters/SeriesIndexPostersConnector';
+import SeriesIndexOverviewOptionsModal from './Overview/Options/SeriesIndexOverviewOptionsModal';
+import SeriesIndexOverviewsConnector from './Overview/SeriesIndexOverviewsConnector';
+import SeriesIndexFooter from './SeriesIndexFooter';
+import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu';
+import SeriesIndexSortMenu from './Menus/SeriesIndexSortMenu';
+import SeriesIndexViewMenu from './Menus/SeriesIndexViewMenu';
+import styles from './SeriesIndex.css';
+
+function getViewComponent(view) {
+ if (view === 'posters') {
+ return SeriesIndexPostersConnector;
+ }
+
+ if (view === 'overview') {
+ return SeriesIndexOverviewsConnector;
+ }
+
+ return SeriesIndexTableConnector;
+}
+
+class SeriesIndex extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ contentBody: null,
+ jumpBarItems: [],
+ jumpToCharacter: null,
+ isPosterOptionsModalOpen: false,
+ isOverviewOptionsModalOpen: false,
+ isRendered: false
+ };
+ }
+
+ componentDidMount() {
+ this.setJumpBarItems();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ sortKey,
+ sortDirection,
+ scrollTop
+ } = this.props;
+
+ if (
+ hasDifferentItems(prevProps.items, items) ||
+ sortKey !== prevProps.sortKey ||
+ sortDirection !== prevProps.sortDirection
+ ) {
+ this.setJumpBarItems();
+ }
+
+ if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) {
+ this.setState({ jumpToCharacter: null });
+ }
+ }
+
+ //
+ // Control
+
+ setContentBodyRef = (ref) => {
+ this.setState({ contentBody: ref });
+ }
+
+ setJumpBarItems() {
+ const {
+ items,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ // Reset if not sorting by sortTitle
+ if (sortKey !== 'sortTitle') {
+ this.setState({ jumpBarItems: [] });
+ return;
+ }
+
+ const characters = _.reduce(items, (acc, item) => {
+ const firstCharacter = item.sortTitle.charAt(0);
+
+ if (isNaN(firstCharacter)) {
+ acc.push(firstCharacter);
+ } else {
+ acc.push('#');
+ }
+
+ return acc;
+ }, []).sort();
+
+ // Reverse if sorting descending
+ if (sortDirection === sortDirections.DESCENDING) {
+ characters.reverse();
+ }
+
+ this.setState({ jumpBarItems: _.sortedUniq(characters) });
+ }
+
+ //
+ // Listeners
+
+ onPosterOptionsPress = () => {
+ this.setState({ isPosterOptionsModalOpen: true });
+ }
+
+ onPosterOptionsModalClose = () => {
+ this.setState({ isPosterOptionsModalOpen: false });
+ }
+
+ onOverviewOptionsPress = () => {
+ this.setState({ isOverviewOptionsModalOpen: true });
+ }
+
+ onOverviewOptionsModalClose = () => {
+ this.setState({ isOverviewOptionsModalOpen: false });
+ }
+
+ onJumpBarItemPress = (jumpToCharacter) => {
+ this.setState({ jumpToCharacter });
+ }
+
+ onRender = () => {
+ this.setState({ isRendered: true }, () => {
+ const {
+ scrollTop,
+ isSmallScreen
+ } = this.props;
+
+ if (isSmallScreen) {
+ // Seems to result in the view being off by 125px (distance to the top of the page)
+ // document.documentElement.scrollTop = document.body.scrollTop = scrollTop;
+
+ // This works, but then jumps another 1px after scrolling
+ document.documentElement.scrollTop = scrollTop;
+ }
+ });
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.props.onScroll({ scrollTop });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ totalItems,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ view,
+ isRefreshingSeries,
+ isRssSyncExecuting,
+ scrollTop,
+ onSortSelect,
+ onFilterSelect,
+ onViewSelect,
+ onRefreshSeriesPress,
+ onRssSyncPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ contentBody,
+ jumpBarItems,
+ jumpToCharacter,
+ isPosterOptionsModalOpen,
+ isOverviewOptionsModalOpen,
+ isRendered
+ } = this.state;
+
+ const ViewComponent = getViewComponent(view);
+ const isLoaded = !!(!error && isPopulated && items.length && contentBody);
+ const hasNoSeries = !totalItems;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {
+ view === 'posters' &&
+
+ }
+
+ {
+ view === 'overview' &&
+
+ }
+
+ {
+ (view === 'posters' || view === 'overview') &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load series
+ }
+
+ {
+ isLoaded &&
+
+
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+ {
+ isLoaded && !!jumpBarItems.length &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesIndex.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalItems: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ view: PropTypes.string.isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ isRssSyncExecuting: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortSelect: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onViewSelect: PropTypes.func.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired,
+ onRssSyncPress: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default SeriesIndex;
diff --git a/frontend/src/Series/Index/SeriesIndexConnector.js b/frontend/src/Series/Index/SeriesIndexConnector.js
new file mode 100644
index 000000000..63a6288cc
--- /dev/null
+++ b/frontend/src/Series/Index/SeriesIndexConnector.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import dimensions from 'Styles/Variables/dimensions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { fetchSeries } from 'Store/Actions/seriesActions';
+import scrollPositions from 'Store/scrollPositions';
+import { setSeriesSort, setSeriesFilter, setSeriesView } from 'Store/Actions/seriesIndexActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import withScrollPosition from 'Components/withScrollPosition';
+import SeriesIndex from './SeriesIndex';
+
+const POSTERS_PADDING = 15;
+const POSTERS_PADDING_SMALL_SCREEN = 5;
+const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding);
+const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen);
+
+// If the scrollTop is greater than zero it needs to be offset
+// by the padding so when it is set initially so it is correct
+// after React Virtualized takes the padding into account.
+
+function getScrollTop(view, scrollTop, isSmallScreen) {
+ if (scrollTop === 0) {
+ return 0;
+ }
+
+ let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING;
+
+ if (view === 'posters') {
+ padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING;
+ }
+
+ return scrollTop + padding;
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('series', 'seriesIndex'),
+ createCommandExecutingSelector(commandNames.REFRESH_SERIES),
+ createCommandExecutingSelector(commandNames.RSS_SYNC),
+ createDimensionsSelector(),
+ (
+ series,
+ isRefreshingSeries,
+ isRssSyncExecuting,
+ dimensionsState
+ ) => {
+ return {
+ ...series,
+ isRefreshingSeries,
+ isRssSyncExecuting,
+ isSmallScreen: dimensionsState.isSmallScreen
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchSeries,
+ setSeriesSort,
+ setSeriesFilter,
+ setSeriesView,
+ executeCommand
+};
+
+class SeriesIndexConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ view,
+ scrollTop,
+ isSmallScreen
+ } = props;
+
+ this.state = {
+ scrollTop: getScrollTop(view, scrollTop, isSmallScreen)
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchSeries();
+ }
+
+ //
+ // Listeners
+
+ onSortSelect = (sortKey) => {
+ this.props.setSeriesSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setSeriesFilter({ selectedFilterKey });
+ }
+
+ onViewSelect = (view) => {
+ // Reset the scroll position before changing the view
+ this.setState({ scrollTop: 0 }, () => {
+ this.props.setSeriesView({ view });
+ });
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.setState({
+ scrollTop
+ }, () => {
+ scrollPositions.seriesIndex = scrollTop;
+ });
+ }
+
+ onRefreshSeriesPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_SERIES
+ });
+ }
+
+ onRssSyncPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RSS_SYNC
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesIndexConnector.propTypes = {
+ isSmallScreen: PropTypes.bool.isRequired,
+ view: PropTypes.string.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ fetchSeries: PropTypes.func.isRequired,
+ setSeriesSort: PropTypes.func.isRequired,
+ setSeriesFilter: PropTypes.func.isRequired,
+ setSeriesView: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withScrollPosition(
+ connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexConnector),
+ 'seriesIndex'
+);
diff --git a/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js b/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js
new file mode 100644
index 000000000..4833f42f2
--- /dev/null
+++ b/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setSeriesFilter } from 'Store/Actions/seriesIndexActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.series.items,
+ (state) => state.seriesIndex.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'seriesIndex'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setSeriesFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/Series/Index/SeriesIndexFooter.css b/frontend/src/Series/Index/SeriesIndexFooter.css
new file mode 100644
index 000000000..3aa369576
--- /dev/null
+++ b/frontend/src/Series/Index/SeriesIndexFooter.css
@@ -0,0 +1,66 @@
+.footer {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 20px;
+ font-size: $smallFontSize;
+}
+
+.legendItem {
+ display: flex;
+ margin-bottom: 4px;
+ line-height: 16px;
+}
+
+.legendItemColor {
+ margin-right: 8px;
+ width: 30px;
+ height: 16px;
+ border-radius: 4px;
+}
+
+.continuing {
+ composes: legendItemColor;
+
+ background-color: $primaryColor;
+}
+
+.ended {
+ composes: legendItemColor;
+
+ background-color: $successColor;
+}
+
+.missingMonitored {
+ composes: legendItemColor;
+
+ background-color: $dangerColor;
+}
+
+.missingUnmonitored {
+ composes: legendItemColor;
+
+ background-color: $warningColor;
+}
+
+.statistics {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+
+@media (max-width: $breakpointLarge) {
+ .statistics {
+ display: block;
+ }
+}
+
+@media (max-width: $breakpointSmall) {
+ .footer {
+ display: block;
+ }
+
+ .statistics {
+ display: flex;
+ margin-top: 20px;
+ }
+}
diff --git a/frontend/src/Series/Index/SeriesIndexFooter.js b/frontend/src/Series/Index/SeriesIndexFooter.js
new file mode 100644
index 000000000..8afba25a7
--- /dev/null
+++ b/frontend/src/Series/Index/SeriesIndexFooter.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './SeriesIndexFooter.css';
+
+function SeriesIndexFooter({ series }) {
+ const count = series.length;
+ let episodes = 0;
+ let episodeFiles = 0;
+ let ended = 0;
+ let continuing = 0;
+ let monitored = 0;
+ let totalFileSize = 0;
+
+ series.forEach((s) => {
+ const { statistics = {} } = s;
+
+ const {
+ episodeCount = 0,
+ episodeFileCount = 0,
+ sizeOnDisk = 0
+ } = statistics;
+
+ episodes += episodeCount;
+ episodeFiles += episodeFileCount;
+
+ if (s.status === 'ended') {
+ ended++;
+ } else {
+ continuing++;
+ }
+
+ if (s.monitored) {
+ monitored++;
+ }
+
+ totalFileSize += sizeOnDisk;
+ });
+
+ return (
+
+
+
+
+
Continuing (All episodes downloaded)
+
+
+
+
+
Ended (All episodes downloaded)
+
+
+
+
+
Missing Episodes (Series monitored)
+
+
+
+
+
Missing Episodes (Series not monitored)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+SeriesIndexFooter.propTypes = {
+ series: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default SeriesIndexFooter;
diff --git a/frontend/src/Series/Index/SeriesIndexItemConnector.js b/frontend/src/Series/Index/SeriesIndexItemConnector.js
new file mode 100644
index 000000000..633fa722b
--- /dev/null
+++ b/frontend/src/Series/Index/SeriesIndexItemConnector.js
@@ -0,0 +1,125 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+
+function selectShowSearchAction() {
+ return createSelector(
+ (state) => state.seriesIndex,
+ (seriesIndex) => {
+ const view = seriesIndex.view;
+
+ switch (view) {
+ case 'posters':
+ return seriesIndex.posterOptions.showSearchAction;
+ case 'overview':
+ return seriesIndex.overviewOptions.showSearchAction;
+ default:
+ return seriesIndex.tableOptions.showSearchAction;
+ }
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ createQualityProfileSelector(),
+ createLanguageProfileSelector(),
+ selectShowSearchAction(),
+ createCommandsSelector(),
+ (
+ series,
+ qualityProfile,
+ languageProfile,
+ showSearchAction,
+ commands
+ ) => {
+ const isRefreshingSeries = commands.some((command) => {
+ return (
+ command.name === commandNames.REFRESH_SERIES &&
+ command.body.seriesId === series.id &&
+ isCommandExecuting(command)
+ );
+ });
+
+ const isSearchingSeries = commands.some((command) => {
+ return (
+ command.name === commandNames.SERIES_SEARCH &&
+ command.body.seriesId === series.id &&
+ isCommandExecuting(command)
+ );
+ });
+
+ const latestSeason = _.maxBy(series.seasons, (season) => season.seasonNumber);
+
+ return {
+ ...series,
+ qualityProfile,
+ languageProfile,
+ latestSeason,
+ showSearchAction,
+ isRefreshingSeries,
+ isSearchingSeries
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class SeriesIndexItemConnector extends Component {
+
+ //
+ // Listeners
+
+ onRefreshSeriesPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_SERIES,
+ seriesId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.SERIES_SEARCH,
+ seriesId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ component: ItemComponent,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+SeriesIndexItemConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ component: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexItemConnector);
diff --git a/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js b/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js
new file mode 100644
index 000000000..d85263469
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
+import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
+
+class SeriesIndexActionsCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditSeriesModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditSeriesModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteSeriesModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ isRefreshingSeries,
+ onRefreshSeriesPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditSeriesModalOpen,
+ isDeleteSeriesModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesIndexActionsCell.propTypes = {
+ id: PropTypes.number.isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired
+};
+
+export default SeriesIndexActionsCell;
diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeader.css b/frontend/src/Series/Index/Table/SeriesIndexHeader.css
new file mode 100644
index 000000000..826d94110
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexHeader.css
@@ -0,0 +1,89 @@
+.status {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortTitle {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 4 0 110px;
+}
+
+.network {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 2 0 90px;
+}
+
+.qualityProfileId,
+.languageProfileId {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 125px;
+}
+
+.nextAiring,
+.previousAiring,
+.added,
+.genres {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 180px;
+}
+
+.seasonCount,
+.certification {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 100px;
+}
+
+.episodeProgress,
+.latestSeason {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 150px;
+}
+
+.episodeCount {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 130px;
+}
+
+.path {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 120px;
+}
+
+.ratings {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 80px;
+}
+
+.tags {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 60px;
+}
+
+.useSceneNumbering {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 145px;
+}
+
+.actions {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 90px;
+}
diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeader.js b/frontend/src/Series/Index/Table/SeriesIndexHeader.js
new file mode 100644
index 000000000..3de1ec64c
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexHeader.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
+import SeriesIndexTableOptionsConnector from './SeriesIndexTableOptionsConnector';
+import styles from './SeriesIndexHeader.css';
+
+class SeriesIndexHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isTableOptionsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onTableOptionsPress = () => {
+ this.setState({ isTableOptionsModalOpen: true });
+ }
+
+ onTableOptionsModalClose = () => {
+ this.setState({ isTableOptionsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ showSearchAction,
+ columns,
+ onTableOptionChange,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ label,
+ isSortable,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {label}
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+SeriesIndexHeader.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default SeriesIndexHeader;
diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js b/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js
new file mode 100644
index 000000000..fa9a895fd
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { setSeriesTableOption } from 'Store/Actions/seriesIndexActions';
+import SeriesIndexHeader from './SeriesIndexHeader';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setSeriesTableOption(payload));
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(SeriesIndexHeader);
diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css
new file mode 100644
index 000000000..2513e1913
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css
@@ -0,0 +1,138 @@
+.cell {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: flex;
+ align-items: center;
+}
+
+.status {
+ composes: cell;
+
+ flex: 0 0 60px;
+}
+
+.sortTitle {
+ composes: cell;
+
+ flex: 4 0 110px;
+}
+
+.banner {
+ flex: 0 0 379px;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ position: relative;
+ display: block;
+ height: 70px;
+ background-color: $defaultColor;
+}
+
+.bannerImage {
+ width: 379px;
+ height: 70px;
+}
+
+.overlayTitle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 5px;
+ width: 100%;
+ height: 100%;
+ color: $offWhite;
+ text-align: center;
+ font-size: 20px;
+}
+
+.network {
+ composes: cell;
+
+ flex: 2 0 90px;
+}
+
+.qualityProfileId,
+.languageProfileId {
+ composes: cell;
+
+ flex: 1 0 125px;
+}
+
+.nextAiring,
+.previousAiring,
+.added,
+.genres {
+ composes: cell;
+
+ flex: 0 0 180px;
+}
+
+.seasonCount,
+.certification {
+ composes: cell;
+
+ flex: 0 0 100px;
+}
+
+.episodeProgress,
+.latestSeason {
+ composes: cell;
+
+ display: flex;
+ justify-content: center;
+ flex: 0 0 150px;
+ flex-direction: column;
+}
+
+.episodeCount {
+ composes: cell;
+
+ flex: 0 0 130px;
+}
+
+.path {
+ composes: cell;
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: cell;
+
+ flex: 0 0 120px;
+}
+
+.ratings {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 80px;
+}
+
+.tags {
+ composes: cell;
+
+ flex: 1 0 60px;
+}
+
+.useSceneNumbering {
+ composes: cell;
+
+ flex: 0 0 145px;
+}
+
+.actions {
+ composes: cell;
+
+ flex: 0 1 90px;
+}
+
+.checkInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.js b/frontend/src/Series/Index/Table/SeriesIndexRow.js
new file mode 100644
index 000000000..b36926c46
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexRow.js
@@ -0,0 +1,515 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import HeartRating from 'Components/HeartRating';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ProgressBar from 'Components/ProgressBar';
+import TagListConnector from 'Components/TagListConnector';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableRow from 'Components/Table/VirtualTableRow';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
+import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
+import SeriesBanner from 'Series/SeriesBanner';
+import SeriesStatusCell from './SeriesStatusCell';
+import styles from './SeriesIndexRow.css';
+
+class SeriesIndexRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasBannerError: false,
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: false
+ };
+ }
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditSeriesModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditSeriesModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditSeriesModalOpen: false,
+ isDeleteSeriesModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteSeriesModalOpen: false });
+ }
+
+ onUseSceneNumberingChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ onBannerLoad = () => {
+ if (this.state.hasBannerError) {
+ this.setState({ hasBannerError: false });
+ }
+ }
+
+ onBannerLoadError = () => {
+ if (!this.state.hasBannerError) {
+ this.setState({ hasBannerError: true });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ monitored,
+ status,
+ title,
+ titleSlug,
+ network,
+ qualityProfile,
+ languageProfile,
+ nextAiring,
+ previousAiring,
+ added,
+ statistics,
+ latestSeason,
+ path,
+ genres,
+ ratings,
+ certification,
+ tags,
+ images,
+ useSceneNumbering,
+ showBanners,
+ showSearchAction,
+ columns,
+ isRefreshingSeries,
+ isSearchingSeries,
+ onRefreshSeriesPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ seasonCount,
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount,
+ sizeOnDisk
+ } = statistics;
+
+ const {
+ hasBannerError,
+ isEditSeriesModalOpen,
+ isDeleteSeriesModalOpen
+ } = this.state;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'status') {
+ return (
+
+ );
+ }
+
+ if (name === 'sortTitle') {
+ return (
+
+ {
+ showBanners ?
+
+
+
+ {
+ hasBannerError &&
+
+ {title}
+
+ }
+ :
+
+
+ }
+
+ );
+ }
+
+ if (name === 'network') {
+ return (
+
+ {network}
+
+ );
+ }
+
+ if (name === 'qualityProfileId') {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (name === 'languageProfileId') {
+ return (
+
+ {languageProfile.name}
+
+ );
+ }
+
+ if (name === 'nextAiring') {
+ return (
+
+ );
+ }
+
+ if (name === 'previousAiring') {
+ return (
+
+ );
+ }
+
+ if (name === 'added') {
+ return (
+
+ );
+ }
+
+ if (name === 'seasonCount') {
+ return (
+
+ {seasonCount}
+
+ );
+ }
+
+ if (name === 'episodeProgress') {
+ const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
+
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'latestSeason') {
+ if (!latestSeason) {
+ return (
+
+ );
+ }
+
+ const seasonStatistics = latestSeason.statistics;
+ const progress = seasonStatistics.episodeCount ? seasonStatistics.episodeFileCount / seasonStatistics.episodeCount * 100 : 100;
+
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeCount') {
+ return (
+
+ {totalEpisodeCount}
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (name === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ if (name === 'genres') {
+ const joinedGenres = genres.join(', ');
+
+ return (
+
+
+ {joinedGenres}
+
+
+ );
+ }
+
+ if (name === 'ratings') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'certification') {
+ return (
+
+ {certification}
+
+ );
+ }
+
+ if (name === 'tags') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'useSceneNumbering') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+SeriesIndexRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ network: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ languageProfile: PropTypes.object.isRequired,
+ nextAiring: PropTypes.string,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ latestSeason: PropTypes.object,
+ path: PropTypes.string.isRequired,
+ genres: PropTypes.arrayOf(PropTypes.string).isRequired,
+ ratings: PropTypes.object.isRequired,
+ certification: PropTypes.string,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ useSceneNumbering: PropTypes.bool.isRequired,
+ showBanners: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ isSearchingSeries: PropTypes.bool.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+SeriesIndexRow.defaultProps = {
+ statistics: {
+ seasonCount: 0,
+ episodeCount: 0,
+ episodeFileCount: 0,
+ totalEpisodeCount: 0
+ },
+ genres: [],
+ tags: []
+};
+
+export default SeriesIndexRow;
diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.css b/frontend/src/Series/Index/Table/SeriesIndexTable.css
new file mode 100644
index 000000000..e46160a96
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexTable.css
@@ -0,0 +1,5 @@
+.tableContainer {
+ composes: tableContainer from 'Components/Table/VirtualTable.css';
+
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.js b/frontend/src/Series/Index/Table/SeriesIndexTable.js
new file mode 100644
index 000000000..d5cae1d51
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexTable.js
@@ -0,0 +1,131 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
+import { sortDirections } from 'Helpers/Props';
+import VirtualTable from 'Components/Table/VirtualTable';
+import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
+import SeriesIndexHeaderConnector from './SeriesIndexHeaderConnector';
+import SeriesIndexRow from './SeriesIndexRow';
+import styles from './SeriesIndexTable.css';
+
+class SeriesIndexTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ scrollIndex: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const jumpToCharacter = this.props.jumpToCharacter;
+
+ if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
+ const items = this.props.items;
+
+ const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter);
+
+ if (scrollIndex != null) {
+ this.setState({ scrollIndex });
+ }
+ } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) {
+ this.setState({ scrollIndex: null });
+ }
+ }
+
+ //
+ // Control
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ columns,
+ showBanners
+ } = this.props;
+
+ const series = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ columns,
+ filters,
+ sortKey,
+ sortDirection,
+ showBanners,
+ isSmallScreen,
+ scrollTop,
+ contentBody,
+ onSortPress,
+ onRender,
+ onScroll
+ } = this.props;
+
+ return (
+
+ }
+ columns={columns}
+ filters={filters}
+ sortKey={sortKey}
+ sortDirection={sortDirection}
+ onRender={onRender}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+SeriesIndexTable.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ showBanners: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ jumpToCharacter: PropTypes.string,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default SeriesIndexTable;
diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js b/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js
new file mode 100644
index 000000000..39804c9c6
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js
@@ -0,0 +1,29 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setSeriesSort } from 'Store/Actions/seriesIndexActions';
+import SeriesIndexTable from './SeriesIndexTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ createClientSideCollectionSelector('series', 'seriesIndex'),
+ (dimensions, series) => {
+ return {
+ isSmallScreen: dimensions.isSmallScreen,
+ ...series,
+ showBanners: series.tableOptions.showBanners
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSortPress(sortKey) {
+ dispatch(setSeriesSort({ sortKey }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexTable);
diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js b/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js
new file mode 100644
index 000000000..52eeda885
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } 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';
+
+class SeriesIndexTableOptions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ showBanners: props.showBanners,
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ showBanners,
+ showSearchAction
+ } = this.props;
+
+ if (
+ showBanners !== prevProps.showBanners ||
+ showSearchAction !== prevProps.showSearchAction
+ ) {
+ this.setState({
+ showBanners,
+ showSearchAction
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onTableOptionChange = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onTableOptionChange({
+ tableOptions: {
+ ...this.state,
+ [name]: value
+ }
+ });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ showBanners,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Show Banners
+
+
+
+
+
+ Show Search
+
+
+
+
+ );
+ }
+}
+
+SeriesIndexTableOptions.propTypes = {
+ showBanners: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default SeriesIndexTableOptions;
diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js b/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js
new file mode 100644
index 000000000..ac608abe5
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import SeriesIndexTableOptions from './SeriesIndexTableOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesIndex.tableOptions,
+ (tableOptions) => {
+ return tableOptions;
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(SeriesIndexTableOptions);
diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.css b/frontend/src/Series/Index/Table/SeriesStatusCell.css
new file mode 100644
index 000000000..a7681e36c
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesStatusCell.css
@@ -0,0 +1,9 @@
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 60px;
+}
+
+.statusIcon {
+ width: 20px !important;
+}
diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.js b/frontend/src/Series/Index/Table/SeriesStatusCell.js
new file mode 100644
index 000000000..44cd42f0e
--- /dev/null
+++ b/frontend/src/Series/Index/Table/SeriesStatusCell.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './SeriesStatusCell.css';
+
+function SeriesStatusCell(props) {
+ const {
+ className,
+ monitored,
+ status,
+ component: Component,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+}
+
+SeriesStatusCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ component: PropTypes.func
+};
+
+SeriesStatusCell.defaultProps = {
+ className: styles.status,
+ component: VirtualTableRowCell
+};
+
+export default SeriesStatusCell;
diff --git a/frontend/src/Series/MoveSeries/MoveSeriesModal.css b/frontend/src/Series/MoveSeries/MoveSeriesModal.css
new file mode 100644
index 000000000..11f33bef2
--- /dev/null
+++ b/frontend/src/Series/MoveSeries/MoveSeriesModal.css
@@ -0,0 +1,5 @@
+.doNotMoveButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Series/MoveSeries/MoveSeriesModal.js b/frontend/src/Series/MoveSeries/MoveSeriesModal.js
new file mode 100644
index 000000000..6d767f22f
--- /dev/null
+++ b/frontend/src/Series/MoveSeries/MoveSeriesModal.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+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 styles from './MoveSeriesModal.css';
+
+function MoveSeriesModal(props) {
+ const {
+ originalPath,
+ destinationPath,
+ destinationRootFolder,
+ isOpen,
+ onSavePress,
+ onMoveSeriesPress
+ } = props;
+
+ if (
+ isOpen &&
+ !originalPath &&
+ !destinationPath &&
+ !destinationRootFolder
+ ) {
+ console.error('orginalPath and destinationPath OR destinationRootFolder must be provided');
+ }
+
+ return (
+
+
+
+ Move Files
+
+
+
+ {
+ destinationRootFolder ?
+ `Would you like to move the series folders to '${destinationRootFolder}'?` :
+ `Would you like to move the series files from '${originalPath}' to '${destinationPath}'?`
+ }
+
+
+
+
+ No, I'll Move the Files Myself
+
+
+
+ Yes, Move the Files
+
+
+
+
+ );
+}
+
+MoveSeriesModal.propTypes = {
+ originalPath: PropTypes.string,
+ destinationPath: PropTypes.string,
+ destinationRootFolder: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onMoveSeriesPress: PropTypes.func.isRequired
+};
+
+export default MoveSeriesModal;
diff --git a/frontend/src/Series/NoSeries.css b/frontend/src/Series/NoSeries.css
new file mode 100644
index 000000000..38a01f391
--- /dev/null
+++ b/frontend/src/Series/NoSeries.css
@@ -0,0 +1,11 @@
+.message {
+ margin-top: 10px;
+ margin-bottom: 30px;
+ text-align: center;
+ font-size: 20px;
+}
+
+.buttonContainer {
+ margin-top: 20px;
+ text-align: center;
+}
diff --git a/frontend/src/Series/NoSeries.js b/frontend/src/Series/NoSeries.js
new file mode 100644
index 000000000..cfcbb53ab
--- /dev/null
+++ b/frontend/src/Series/NoSeries.js
@@ -0,0 +1,51 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import styles from './NoSeries.css';
+
+function NoSeries(props) {
+ const { totalItems } = props;
+
+ if (totalItems > 0) {
+ return (
+
+
+ All series are hidden due to the applied filter.
+
+
+ );
+ }
+
+ return (
+
+
+ No series found, to get started you'll want to add a new series or import some existing ones.
+
+
+
+
+ Import Existing Series
+
+
+
+
+
+ Add New Series
+
+
+
+ );
+}
+
+NoSeries.propTypes = {
+ totalItems: PropTypes.number.isRequired
+};
+
+export default NoSeries;
diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModal.js b/frontend/src/Series/Search/SeasonInteractiveSearchModal.js
new file mode 100644
index 000000000..7973affba
--- /dev/null
+++ b/frontend/src/Series/Search/SeasonInteractiveSearchModal.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import SeasonInteractiveSearchModalContent from './SeasonInteractiveSearchModalContent';
+
+function SeasonInteractiveSearchModal(props) {
+ const {
+ isOpen,
+ seriesId,
+ seasonNumber,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+SeasonInteractiveSearchModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeasonInteractiveSearchModal;
diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js b/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js
new file mode 100644
index 000000000..e270ebdec
--- /dev/null
+++ b/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
+import SeasonInteractiveSearchModal from './SeasonInteractiveSearchModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ dispatch(cancelFetchReleases());
+ dispatch(clearReleases());
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(SeasonInteractiveSearchModal);
diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js b/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js
new file mode 100644
index 000000000..536c48a40
--- /dev/null
+++ b/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from 'Components/Link/Button';
+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 InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
+import SeasonNumber from 'Season/SeasonNumber';
+
+function SeasonInteractiveSearchModalContent(props) {
+ const {
+ seriesId,
+ seasonNumber,
+ onModalClose
+ } = props;
+
+ return (
+
+
+ Interactive Search {seasonNumber != null && }
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+SeasonInteractiveSearchModalContent.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeasonInteractiveSearchModalContent;
diff --git a/frontend/src/Series/SeriesBanner.js b/frontend/src/Series/SeriesBanner.js
new file mode 100644
index 000000000..13aa8c117
--- /dev/null
+++ b/frontend/src/Series/SeriesBanner.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SeriesImage from './SeriesImage';
+
+const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXsAAABGCAIAAACiz6ObAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjEuMWMqnEsAAAVeSURBVHhe7d3dduI4EEXheaMOfzPv/2ZzpCqLsmULQWjf1P4WkwnEtrhhr7IhnX9uAHAWigPgPBQHwHkoDoDzUBwA56E4AM5DcQCch+IAOA/FwQfuuonfA6ZRHLymuDwej3+r/zp6TI9rAxqElygODtXQ7CRmwNLj+wMdioMdas3uODOPkQe7KA5Wft+aiO5gg+LAfbc1DedZiCgOzF/JTaOD+zrIjeKguF6vmnE8D9+mlKloWsIXQ2IUByU3Rqc/HomviktQneQoTnaXy/Wi/xbfnXQ03eiAfuirL+QLIyWKk1oLQWhOic5XrunoIJvc+DK+ODKiOEmpBY9HuZpbaxByUOnxX0bHLhX74Zbpxuhx3r1Ki+IkZUGJXVAS+i5YPt5io83zsOuztrY00cmJ4mSkIlgdZBWdy/Xn51kHozTMjzuxNSbmRuKvTdTnglwoTkY2ZTS66z2ogdhEx+4oJZu9Gj2uKmmDuuHKj44VirMZmix2SIXipBMHnGZ9TWdbCrPct8M43dVD/cY6QJebnWDZTIQ8KE46R6OKBhBvQ51NdqMzQ3tp1z9/ygHsES26mW4axpxsKE4uuwNO086MajU+iY7vGHIjR7kxelL+5JAAxcnlaMAx+mnrhLVDo8pb0VFoSmxCbhS50ZK8aZUMxcnFX+XH4gVgi04fHD2iH+2WqH/8fn/xFjsnVqlQnETGp1Qmjjk91URTT7vZ2dNgBtKi46lKKE4qFCeR8fWUxt5+6pWTrHqe1d+OqqNF/aBDvGOVB8VJZLI49/CmVWPXdEz5pr91Hx2UmalKKE4eFCeRlyc45hE+EGjsZMpa03/T7vaTzmTjuHicB8VJZLI42syDsShRWXhrluK0R8rdneLMNY7ipEFxEpksjngwFq0pJTXt++4mvsNidqqiOGlQnETeKE78bcxLKU4dZupXad+Y8FPfZUFxsEFxEpkvjjb2ZtRP37QRZvvNMt34XYqDVyhOIm/MOPEN8kFxFu2u77KgONigOIlMvv61lQdj8fzg3xKXzc2Gnf4NcoqDDYqTyHRxdj52XCeZ8mXANw2UEj/o0P1OcbKgOIlMvv61WV+cS/0VTZ9o9iad3Y8d8wlAbFCcRCZf/9rSg7GmqFhcNsXR7POb33LQSEVx8qA4iUwVp7uIE6ksJTdt2Cn12W+N0aIvT+W0gT09ZEBxcnn5+leVvBb1ffH6q+FdU/SA3TqlQOvtX57KUZxUKE4u49e/Xvzts3/KhurRF2Ss7LI+ydKi48xxSpUKxUln8PqPA84HuTHltKte6/H7wzFHz8WfFnKgOOkcFcfObqwRPqoMr9EMLNHx3QeLKkb1SSELipPO7vXjmBspI8r7001ULyo/x5z7wZhjTwl5UJyMNqc5ys36gnHhd6K6r7ZclL+KJ/Vh1Wr1nnrZP/z9X/1Pe/p6CwachChORspEO80Z58Y2VhqOTouMfliPU/8yZ5iV4tFKdG6rde3JIBWKk5SNOfaytyJI35pxaHYpTrE7OuT6sOWYom3qE0EuFCevelLj042SELugMHzQmmj9Z4UL+17UGnKTFsVJzRKwzc31qjnFy/ELatZzifJhZV/ClkZOFCe7koPwLrjK88vpJtKk48et0bGFfGGkRHFwiwPOF3Nj7A0pO7gWshWRFsVBoRzo69dzY1p06lJIjeLA3ef+LYsP2AUdQCgOnhSdv3FWxTtTaCgOtr7VHR3DzqeAhuJgn2Lh5XifgkVrsIvi4JCGHYVD+ZifeLQp51AYoDiYoYyU+hwpPyY0mEBxAJyH4gA4D8UBcB6KA+A8FAfAeSgOgPNQHADnoTgAzkNxAJzldvsfnbIbPuBaveQAAAAASUVORK5CYII=';
+
+function SeriesBanner(props) {
+ return (
+
+ );
+}
+
+SeriesBanner.propTypes = {
+ size: PropTypes.number.isRequired
+};
+
+SeriesBanner.defaultProps = {
+ size: 70
+};
+
+export default SeriesBanner;
diff --git a/frontend/src/Series/SeriesImage.js b/frontend/src/Series/SeriesImage.js
new file mode 100644
index 000000000..6c99be067
--- /dev/null
+++ b/frontend/src/Series/SeriesImage.js
@@ -0,0 +1,198 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+function findImage(images, coverType) {
+ return images.find((image) => image.coverType === coverType);
+}
+
+function getUrl(image, coverType, size) {
+ if (image) {
+ // Remove protocol
+ let url = image.url.replace(/^https?:/, '');
+ url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
+
+ return url;
+ }
+}
+
+class SeriesImage extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.floor(window.devicePixelRatio);
+
+ const {
+ images,
+ coverType,
+ size
+ } = props;
+
+ const image = findImage(images, coverType);
+
+ this.state = {
+ pixelRatio,
+ image,
+ url: getUrl(image, coverType, pixelRatio * size),
+ isLoaded: false,
+ hasError: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.state.url && this.props.onError) {
+ this.props.onError();
+ }
+ }
+
+ componentDidUpdate() {
+ const {
+ images,
+ coverType,
+ placeholder,
+ size,
+ onError
+ } = this.props;
+
+ const {
+ image,
+ pixelRatio
+ } = this.state;
+
+ const nextImage = findImage(images, coverType);
+
+ if (nextImage && (!image || nextImage.url !== image.url)) {
+ this.setState({
+ image: nextImage,
+ url: getUrl(nextImage, coverType, pixelRatio * size),
+ hasError: false
+ // Don't reset isLoaded, as we want to immediately try to
+ // show the new image, whether an image was shown previously
+ // or the placeholder was shown.
+ });
+ } else if (!nextImage && image) {
+ this.setState({
+ image: nextImage,
+ url: placeholder,
+ hasError: false
+ });
+
+ if (onError) {
+ onError();
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({
+ hasError: true
+ });
+
+ if (this.props.onError) {
+ this.props.onError();
+ }
+ }
+
+ onLoad = () => {
+ this.setState({
+ isLoaded: true,
+ hasError: false
+ });
+
+ if (this.props.onLoad) {
+ this.props.onLoad();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ placeholder,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ url,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !url) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+SeriesImage.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ coverType: PropTypes.string.isRequired,
+ placeholder: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ lazy: PropTypes.bool.isRequired,
+ overflow: PropTypes.bool.isRequired,
+ onError: PropTypes.func,
+ onLoad: PropTypes.func
+};
+
+SeriesImage.defaultProps = {
+ size: 250,
+ lazy: true,
+ overflow: false
+};
+
+export default SeriesImage;
diff --git a/frontend/src/Series/SeriesPoster.js b/frontend/src/Series/SeriesPoster.js
new file mode 100644
index 000000000..2b04f2883
--- /dev/null
+++ b/frontend/src/Series/SeriesPoster.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SeriesImage from './SeriesImage';
+
+const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAKHklEQVR42u2c25bbKhJATTmUPAZKPerBjTMo0fn/n5wHSYBkXUDCnXPWwEPaneVIO0XdKAouzT9kXApoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gP4DQLXW+vF4GGMeD6211n87UK2NsW33OlprTB7eSw5I220PmwH2JKh+7EGOoj3Lejkly0hKx/pHQLVpu9RhzbeDHsEc1PU7QbXpDo/WfB/oQWmeUoADoMZ2Z4fV7wfV5zG7ruvMu0FPzvpxoV7+hDiPCDUJVLddzmHfBfqZlzONNAG0VrXNy/lB7wCtifKSth+KKD8oEREpshk5JRFRnRm0VkREJLKR2kYQERF9ZAUdHkokM5EO8iQiQRlBiSG552Yhdf91wfDf2UBrkj+Q6nyk9mPklAzj9PQSqZ/qR0aZtrWXZ0UUZfuXKL9ERBKzkdray/Nf/YcsoIrmpOcsynMKqMZHngfVn4MHJeVJz/jTYN7RORN1GlTb7tM5Eqw86fMg55Pc47jjpGY3698DtV3Xfgo1kjqZEulD4tTKafrVO+cP23WPU6Bm6vSC2SfVJK/w2p8fntPPu6ht13WtPgE6SK0dSeuQlMSn/ZWW1EvHWYGYOxF7AtTOAzMpHpKKRwqm8jpZMfHq7MxhUD+3bXMb9QmwdwIDqrYx6bS1WnhMuoWcrX/JQdBw5RHMPgQyJRKiee6w/rLmM8RclueOSC9RAp1YlPyBKnirEoK0sXZVlk9NQrh/URMhm9mRG/oQ6Mz/tKGehqRESgjVaGPsRLSttU+jGxJCBt+Vap1zy542QJ9/zYTjPL/iWAmasd4EUdNoYx7m68sYrT8/ahJTSlIVIrq/kc18HvQB0AWH3jhBIuN3ehlSSiGEFEoKIYWQcv4FVQGwSjlP3MavS9dBl2Lk5xiiGICPp/EDOQBzetMs6LVOBl2MkL/G5BkAYEmmm0NVAAAuIi1xrov0EmfyLqVQnhThni5Pz7mSgOlE0JXqTaulI0WAW4o8kfGAUz7SKlJroGuxsXUxiXO8Tn3/jjwZIvfypLUXJIKuppvGp+eAHKp4TkDwGaj4ufYCnQS6kWz6xBcQgVWRdsT4FcKMfjXqPpNAN1JN4xRT8CtCnEXdGCD6zI6E3citU0A3lkStEymJKwPGZQSoRBbIk+THRg6TArq5zDA+wxDAcMZZKymlVK82D2Ga9zO5En0A1AYUwYKiF5XAYQgxllbGZCD4FrXJ5d1Lqop2XauDd05EJypkDBgHYIxxrNaU4ra9ZaHjQTdX7a0Vaun1Aq8AAAA4/MGwWvzilimtzv0leea7rq0XRKVuwELQ4aNY4my+CbTTC69HAHgFDVx8sBIxB/YgLinx0/lkscgJiAgAHJEDICICcFyQqdirB0WD7lUWLKlXTgQERARE4IjAAThH5K+zv1+40rGguz0izUxJb6E4e9l6HeBzge7uVz1ygc6VVKBjG37wAHSeuIjdUpCJBd2tJ3yJeWY06OQg10GwAzuIN4Hu1+nMZOrltRclH7S0l2ivrr2Upzq6W7G02UCn1lQxBOQcOCBw4JwDAFwHSg4I04LF/vZfTlA5WWP04R0QARAAOSBcERGG31k493LfBNp8oB9yakq97cxCqDMohpO4tF9VywfaBDISzr4XItNAG/6/IkrV2UDb/wSgdzayIf+7gXYBaH1ng29yUdP/gtjHU+lz05jibz6J6kBEzoHy8AcfP3PEScD/VtBJaKogiJpjZOKBDuDE5X8r6K9dUJyA/j0kegevk5MQ6gIT+3NWryfuiY/JKALiFQA4R47IB2qc+tFvBW3UJDL1wkNAuCLnCPw6ps8c+VRFSex3T70pMlEfQgHh2ufPCFfoQ+iop6JOikzvSkrEIFG4YuDjPSibJCUyX1Kyn48+J6AKt0Mou6WtRBbrZMdAzbRmI9jo7H0kxd5FcYRplkdK7YKabEsRI2aFJeS9jY/pXv+p/3Cdre7Ef78NtJ0v7CUHQOQ4WHmf3l9HhzUv6Ox6fJ1tudzMl8CCuwwKAQBYYFWUvArVuQoQr+t6EnwlhOJrBXLPmtpsJR0jlkpki6CvnKT2KiXxJZ0dl/x7qfZECoE5VzrqwWLdfC8tiS+S7VjTZGk3FSrvSRGBM0Bc/p78sMkqeqSQ+9uKtVK9QAQGDBgDfNmAjq6SJYBul8b1pMo9V8D7XVTVXcwoJ1u82wlUSml8M8EJbV4s7TPVS9u17B5bw0/ZbNice7/RRAoZrJS/Z3bGryHp7Zlp+2Zr7n/7wrhEhvwSsXMrGOdhbrLVhWjTthjX5+Z584L6wafZ+wYpcM6idu5M2qat2d8LVQjIGaoYUKoY8nA7ct1Vp23ars+9EQEnxnIS3QEhIJUm8bTDZa/b7WUn1PW9AiCP5uzzlnD11MaXxQ+0anSurfKlSrdPOqk+r3RApPeULJ8Isr6PGID3IbJe959T5yqmK1Kb0qmx0U60KNJxmdwvN+W+q59F2LBg1sRv1m93ki11JXlDWszg9i0qUBelEwS6BfoqUqP8ImmZUykphRJCSKnUwhfuWAX9Gia+kWyz29Gu7IXUhFxUYjrPSgpxE5Lq/pDKR01S3MR8H1pJuju/r+SjjRXoJuhjbXMJ5+0ZStwENfpp+9H2P/pex9scVnjS2ZaTPdqRa5c7NJBNXy0ENcYud5Dap/mUNznbPxtnQ00TPn0UNHzKw8uTyWnvaGPtViZs22czTU/HjlxFMlyW2OPN2G5mfn+5PlAEFfaQyK+IJufWPijUAAxmX0e1OO/14VsnTznae6ifkqIPtLaGwjYd13AgHak5AzqkewEnHsLsSfzCpb77bkL5tdVBFnsEw/T27uwojEbJ526tDvR0fFKtpN6d+IjTN6brHtJHeOfyqTlyrCU4g+E9v1J62+LjzjNZV2NUXp5KHTrT0nWtVguzo/TuQeZ9UE2vJ1rUoFdHhlHSxVOvs1nO3PW5csgpjnN2nfGezulpplOMpKgO4qYSp07Zt0/n/hGpJlKZDgc2TdM/03m+R3dqtDOZRp0KjjxpK4GP+e5pzq7rjJfpj6wnbRvya50MnF3nZl8BNjlBGz/vpssx/Ow3eUHHc+syD+e4A6SiD9gn3FhARErl4uzXNapu3gDa1IrycXadIXrL1QpN09Q5ORPv/0i7pyQvqH4faM4bVRKvfkm+SyeTUJMvU0q/nSiLUNOvJzpy39Ppi3+OXPh06GIq/fzWWT8Oegb16F1vh295O3Z72uG7087cm6cT7/z66wTm2ZsIU8RqT93vd/puRx0n1/O3O+a4LVM/NmFtlvsyc90/qrUxz5fT4MZku4Q0/42uWue+I/VNoG8aBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpA/99B/wd7kHH8CSaCpAAAAABJRU5ErkJggg==';
+
+function SeriesPoster(props) {
+ return (
+
+ );
+}
+
+SeriesPoster.propTypes = {
+ size: PropTypes.number.isRequired
+};
+
+SeriesPoster.defaultProps = {
+ size: 250
+};
+
+export default SeriesPoster;
diff --git a/frontend/src/Series/SeriesTitleLink.js b/frontend/src/Series/SeriesTitleLink.js
new file mode 100644
index 000000000..b91934a28
--- /dev/null
+++ b/frontend/src/Series/SeriesTitleLink.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+
+function SeriesTitleLink({ titleSlug, title }) {
+ const link = `/series/${titleSlug}`;
+
+ return (
+
+ {title}
+
+ );
+}
+
+SeriesTitleLink.propTypes = {
+ titleSlug: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired
+};
+
+export default SeriesTitleLink;
diff --git a/frontend/src/Settings/AdvancedSettingsButton.css b/frontend/src/Settings/AdvancedSettingsButton.css
new file mode 100644
index 000000000..4b7460ea2
--- /dev/null
+++ b/frontend/src/Settings/AdvancedSettingsButton.css
@@ -0,0 +1,31 @@
+.button {
+ composes: toolbarButton from 'Components/Page/Toolbar/PageToolbarButton.css';
+
+ position: relative;
+}
+
+.labelContainer {
+ composes: labelContainer from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.label {
+ composes: label from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.indicatorContainer {
+ position: absolute;
+ top: 10px;
+ right: 12px;
+}
+
+.indicatorBackground {
+ color: $themeDarkColor;
+}
+
+.enabled {
+ color: $successColor;
+}
+
+.disabled {
+ color: $dangerColor;
+}
diff --git a/frontend/src/Settings/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js
new file mode 100644
index 000000000..12d9902d5
--- /dev/null
+++ b/frontend/src/Settings/AdvancedSettingsButton.js
@@ -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 Link from 'Components/Link/Link';
+import styles from './AdvancedSettingsButton.css';
+
+function AdvancedSettingsButton(props) {
+ const {
+ advancedSettings,
+ onAdvancedSettingsPress
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {advancedSettings ? 'Hide Advanced' : 'Show Advanced'}
+
+
+
+ );
+}
+
+AdvancedSettingsButton.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ onAdvancedSettingsPress: PropTypes.func.isRequired
+};
+
+export default AdvancedSettingsButton;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
new file mode 100644
index 000000000..c82604a88
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
+import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
+import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
+
+class DownloadClientSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isTestingAll,
+ dispatchTestAllDownloadClients
+ } = this.props;
+
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClientSettings.propTypes = {
+ isTestingAll: PropTypes.bool.isRequired,
+ dispatchTestAllDownloadClients: PropTypes.func.isRequired
+};
+
+export default DownloadClientSettings;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js
new file mode 100644
index 000000000..5e1a8a1ca
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllDownloadClients } from 'Store/Actions/settingsActions';
+import DownloadClientSettings from './DownloadClientSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients.isTestingAll,
+ (isTestingAll) => {
+ return {
+ isTestingAll
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchTestAllDownloadClients: testAllDownloadClients
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css
new file mode 100644
index 000000000..e1032ddef
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css
@@ -0,0 +1,44 @@
+.downloadClient {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js
new file mode 100644
index 000000000..3a2265d28
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem';
+import styles from './AddDownloadClientItem.css';
+
+class AddDownloadClientItem extends Component {
+
+ //
+ // Listeners
+
+ onDownloadClientSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onDownloadClientSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onDownloadClientSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddDownloadClientItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onDownloadClientSelect: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientItem;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js
new file mode 100644
index 000000000..0c21e7dbd
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector';
+
+function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddDownloadClientModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientModal;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css
new file mode 100644
index 000000000..b4d5c6787
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css
@@ -0,0 +1,5 @@
+.downloadClients {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js
new file mode 100644
index 000000000..65ccf7b41
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 AddDownloadClientItem from './AddDownloadClientItem';
+import styles from './AddDownloadClientModalContent.css';
+
+class AddDownloadClientModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetDownloadClients,
+ torrentDownloadClients,
+ onDownloadClientSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add DownloadClient
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new downloadClient, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+
+ Sonarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
+ For more information on the individual downloadClients, clink on the info buttons.
+
+
+
+
+ {
+ usenetDownloadClients.map((downloadClient) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ torrentDownloadClients.map((downloadClient) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddDownloadClientModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
+ torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDownloadClientSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientModalContent;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js
new file mode 100644
index 000000000..99d5c4f19
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions';
+import AddDownloadClientModalContent from './AddDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients,
+ (downloadClients) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = downloadClients;
+
+ const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' });
+ const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetDownloadClients,
+ torrentDownloadClients
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClientSchema,
+ selectDownloadClientSchema
+};
+
+class AddDownloadClientModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClientSchema();
+ }
+
+ //
+ // Listeners
+
+ onDownloadClientSelect = ({ implementation }) => {
+ this.props.selectDownloadClientSchema({ implementation });
+ this.props.onModalClose({ downloadClientSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddDownloadClientModalContentConnector.propTypes = {
+ fetchDownloadClientSchema: PropTypes.func.isRequired,
+ selectDownloadClientSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js
new file mode 100644
index 000000000..f356f8140
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddDownloadClientPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddDownloadClientPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientPresetMenuItem;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css
new file mode 100644
index 000000000..cfeacec77
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css
@@ -0,0 +1,19 @@
+.downloadClient {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
new file mode 100644
index 000000000..6a86fef16
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
@@ -0,0 +1,113 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
+import styles from './DownloadClient.css';
+
+class DownloadClient extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditDownloadClientModalOpen: false,
+ isDeleteDownloadClientModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditDownloadClientPress = () => {
+ this.setState({ isEditDownloadClientModalOpen: true });
+ }
+
+ onEditDownloadClientModalClose = () => {
+ this.setState({ isEditDownloadClientModalOpen: false });
+ }
+
+ onDeleteDownloadClientPress = () => {
+ this.setState({
+ isEditDownloadClientModalOpen: false,
+ isDeleteDownloadClientModalOpen: true
+ });
+ }
+
+ onDeleteDownloadClientModalClose= () => {
+ this.setState({ isDeleteDownloadClientModalOpen: false });
+ }
+
+ onConfirmDeleteDownloadClient = () => {
+ this.props.onConfirmDeleteDownloadClient(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enable
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ enable ?
+
+ Enabled
+ :
+
+ Disabled
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClient.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enable: PropTypes.bool.isRequired,
+ onConfirmDeleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default DownloadClient;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css
new file mode 100644
index 000000000..ad53e6311
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css
@@ -0,0 +1,20 @@
+.downloadClients {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addDownloadClient {
+ composes: downloadClient from './DownloadClient.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
new file mode 100644
index 000000000..029845025
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import DownloadClient from './DownloadClient';
+import AddDownloadClientModal from './AddDownloadClientModal';
+import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
+import styles from './DownloadClients.css';
+
+class DownloadClients extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddDownloadClientModalOpen: false,
+ isEditDownloadClientModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddDownloadClientPress = () => {
+ this.setState({ isAddDownloadClientModalOpen: true });
+ }
+
+ onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => {
+ this.setState({
+ isAddDownloadClientModalOpen: false,
+ isEditDownloadClientModalOpen: downloadClientSelected
+ });
+ }
+
+ onEditDownloadClientModalClose = () => {
+ this.setState({ isEditDownloadClientModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteDownloadClient,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddDownloadClientModalOpen,
+ isEditDownloadClientModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClients.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default DownloadClients;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js
new file mode 100644
index 000000000..d318bc163
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDownloadClients, deleteDownloadClient } from 'Store/Actions/settingsActions';
+import DownloadClients from './DownloadClients';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients,
+ (downloadClients) => {
+ return {
+ ...downloadClients
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClients,
+ deleteDownloadClient
+};
+
+class DownloadClientsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClients();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteDownloadClient = (id) => {
+ this.props.deleteDownloadClient({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientsConnector.propTypes = {
+ fetchDownloadClients: PropTypes.func.isRequired,
+ deleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js
new file mode 100644
index 000000000..f6b07599c
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector';
+
+function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditDownloadClientModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditDownloadClientModal;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js
new file mode 100644
index 000000000..b5e5520fb
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelTestDownloadClient, cancelSaveDownloadClient } from 'Store/Actions/settingsActions';
+import EditDownloadClientModal from './EditDownloadClientModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.downloadClients';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestDownloadClient() {
+ dispatch(cancelTestDownloadClient({ section }));
+ },
+
+ dispatchCancelSaveDownloadClient() {
+ dispatch(cancelSaveDownloadClient({ section }));
+ }
+ };
+}
+
+class EditDownloadClientModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestDownloadClient();
+ this.props.dispatchCancelSaveDownloadClient();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestDownloadClient,
+ dispatchCancelSaveDownloadClient,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditDownloadClientModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestDownloadClient: PropTypes.func.isRequired,
+ dispatchCancelSaveDownloadClient: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css
new file mode 100644
index 000000000..c73406b57
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css
@@ -0,0 +1,11 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.message {
+ composes: alert from 'Components/Alert.css';
+
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js
new file mode 100644
index 000000000..60eff3eb8
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditDownloadClientModalContent.css';
+
+class EditDownloadClientModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteDownloadClientPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ enable,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Download Client - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new download client, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+EditDownloadClientModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isTesting: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteDownloadClientPress: PropTypes.func
+};
+
+export default EditDownloadClientModalContent;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
new file mode 100644
index 000000000..75f6f0bc3
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions';
+import EditDownloadClientModalContent from './EditDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('downloadClients'),
+ (advancedSettings, downloadClient) => {
+ return {
+ advancedSettings,
+ ...downloadClient
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDownloadClientValue,
+ setDownloadClientFieldValue,
+ saveDownloadClient,
+ testDownloadClient
+};
+
+class EditDownloadClientModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDownloadClientValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setDownloadClientFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveDownloadClient({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testDownloadClient({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDownloadClientModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDownloadClientValue: PropTypes.func.isRequired,
+ setDownloadClientFieldValue: PropTypes.func.isRequired,
+ saveDownloadClient: PropTypes.func.isRequired,
+ testDownloadClient: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
new file mode 100644
index 000000000..c345feb5b
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
@@ -0,0 +1,116 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function DownloadClientOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
Unable to load download client options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+
+
+
+
+
+
+
+
+ }
+
+ );
+}
+
+DownloadClientOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default DownloadClientOptions;
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
new file mode 100644
index 000000000..ef97de47f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchDownloadClientOptions, setDownloadClientOptionsValue, saveDownloadClientOptions } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import DownloadClientOptions from './DownloadClientOptions';
+
+const SECTION = 'downloadClientOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchDownloadClientOptions: fetchDownloadClientOptions,
+ dispatchSetDownloadClientOptionsValue: setDownloadClientOptionsValue,
+ dispatchSaveDownloadClientOptions: saveDownloadClientOptions,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class DownloadClientOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchDownloadClientOptions,
+ dispatchSaveDownloadClientOptions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchDownloadClientOptions();
+ onChildMounted(dispatchSaveDownloadClientOptions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetDownloadClientOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientOptionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchDownloadClientOptions: PropTypes.func.isRequired,
+ dispatchSetDownloadClientOptionsValue: PropTypes.func.isRequired,
+ dispatchSaveDownloadClientOptions: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientOptionsConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js
new file mode 100644
index 000000000..f66113619
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditRemotePathMappingModalContentConnector from './EditRemotePathMappingModalContentConnector';
+
+function EditRemotePathMappingModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditRemotePathMappingModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditRemotePathMappingModal;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js
new file mode 100644
index 000000000..94172429d
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditRemotePathMappingModal from './EditRemotePathMappingModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditRemotePathMappingModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.remotePathMappings' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRemotePathMappingModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css
new file mode 100644
index 000000000..0071acc4e
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css
@@ -0,0 +1,12 @@
+.body {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ flex: 1 1 430px;
+}
+
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js
new file mode 100644
index 000000000..7172bd31e
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js
@@ -0,0 +1,152 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditRemotePathMappingModalContent.css';
+
+function EditRemotePathMappingModalContent(props) {
+ const {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ downloadClientHosts,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteRemotePathMappingPress,
+ ...otherProps
+ } = props;
+
+ const {
+ host,
+ remotePath,
+ localPath
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Remote Path Mapping' : 'Add Remote Path Mapping'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new remote path mapping, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+const remotePathMappingShape = {
+ host: PropTypes.shape(stringSettingShape).isRequired,
+ remotePath: PropTypes.shape(stringSettingShape).isRequired,
+ localPath: PropTypes.shape(stringSettingShape).isRequired
+};
+
+EditRemotePathMappingModalContent.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.shape(remotePathMappingShape).isRequired,
+ downloadClientHosts: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteRemotePathMappingPress: PropTypes.func
+};
+
+export default EditRemotePathMappingModalContent;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js
new file mode 100644
index 000000000..ae0e51bc0
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js
@@ -0,0 +1,138 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setRemotePathMappingValue, saveRemotePathMapping } from 'Store/Actions/settingsActions';
+import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent';
+
+const newRemotePathMapping = {
+ host: '',
+ remotePath: '',
+ localPath: ''
+};
+
+const selectDownloadClientHosts = createSelector(
+ (state) => state.settings.downloadClients.items,
+ (downloadClients) => {
+ return downloadClients.reduce((acc, downloadClient) => {
+ const host = downloadClient.fields.find((field) => {
+ return field.name === 'host';
+ });
+
+ if (host && !acc.includes(host.value)) {
+ acc.push(host.value);
+ }
+
+ return acc;
+ }, []);
+ }
+);
+
+function createRemotePathMappingSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.remotePathMappings,
+ selectDownloadClientHosts,
+ (id, remotePathMappings, downloadClientHosts) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = remotePathMappings;
+
+ const mapping = id ? _.find(items, { id }) : newRemotePathMapping;
+ const settings = selectSettings(mapping, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings,
+ downloadClientHosts
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createRemotePathMappingSelector(),
+ (remotePathMapping) => {
+ return {
+ ...remotePathMapping
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetRemotePathMappingValue: setRemotePathMappingValue,
+ dispatchSaveRemotePathMapping: saveRemotePathMapping
+};
+
+class EditRemotePathMappingModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newRemotePathMapping).forEach((name) => {
+ this.props.dispatchSetRemotePathMappingValue({
+ name,
+ value: newRemotePathMapping[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetRemotePathMappingValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.dispatchSaveRemotePathMapping({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRemotePathMappingModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ dispatchSetRemotePathMappingValue: PropTypes.func.isRequired,
+ dispatchSaveRemotePathMapping: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css
new file mode 100644
index 000000000..a79efda26
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css
@@ -0,0 +1,23 @@
+.remotePathMapping {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid $borderColor;
+ line-height: 30px;
+}
+
+.host {
+ flex: 0 0 300px;
+}
+
+.path {
+ flex: 0 0 400px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 0 auto;
+ padding-right: 10px;
+}
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js
new file mode 100644
index 000000000..3f25dbd0f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js
@@ -0,0 +1,114 @@
+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 Link from 'Components/Link/Link';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
+import styles from './RemotePathMapping.css';
+
+class RemotePathMapping extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditRemotePathMappingModalOpen: false,
+ isDeleteRemotePathMappingModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditRemotePathMappingPress = () => {
+ this.setState({ isEditRemotePathMappingModalOpen: true });
+ }
+
+ onEditRemotePathMappingModalClose = () => {
+ this.setState({ isEditRemotePathMappingModalOpen: false });
+ }
+
+ onDeleteRemotePathMappingPress = () => {
+ this.setState({
+ isEditRemotePathMappingModalOpen: false,
+ isDeleteRemotePathMappingModalOpen: true
+ });
+ }
+
+ onDeleteRemotePathMappingModalClose = () => {
+ this.setState({ isDeleteRemotePathMappingModalOpen: false });
+ }
+
+ onConfirmDeleteRemotePathMapping = () => {
+ this.props.onConfirmDeleteRemotePathMapping(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ host,
+ remotePath,
+ localPath
+ } = this.props;
+
+ return (
+
+
{host}
+
{remotePath}
+
{localPath}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RemotePathMapping.propTypes = {
+ id: PropTypes.number.isRequired,
+ host: PropTypes.string.isRequired,
+ remotePath: PropTypes.string.isRequired,
+ localPath: PropTypes.string.isRequired,
+ onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+RemotePathMapping.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default RemotePathMapping;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css
new file mode 100644
index 000000000..4ef9dcb0f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css
@@ -0,0 +1,23 @@
+.remotePathMappingsHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.host {
+ flex: 0 0 300px;
+}
+
+.path {
+ flex: 0 0 400px;
+}
+
+.addRemotePathMapping {
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 10px;
+}
+
+.addButton {
+ text-align: center;
+}
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
new file mode 100644
index 000000000..f633a3279
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import RemotePathMapping from './RemotePathMapping';
+import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
+import styles from './RemotePathMappings.css';
+
+class RemotePathMappings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddRemotePathMappingModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddRemotePathMappingPress = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteRemotePathMapping,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
Host
+
Remote Path
+
Local Path
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RemotePathMappings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default RemotePathMappings;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js
new file mode 100644
index 000000000..7a029818a
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchRemotePathMappings, deleteRemotePathMapping } from 'Store/Actions/settingsActions';
+import RemotePathMappings from './RemotePathMappings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.remotePathMappings,
+ (remotePathMappings) => {
+ return {
+ ...remotePathMappings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchRemotePathMappings: fetchRemotePathMappings,
+ dispatchDeleteRemotePathMapping: deleteRemotePathMapping
+};
+
+class RemotePathMappingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchRemotePathMappings();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteRemotePathMapping = (id) => {
+ this.props.dispatchDeleteRemotePathMapping({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RemotePathMappingsConnector.propTypes = {
+ dispatchFetchRemotePathMappings: PropTypes.func.isRequired,
+ dispatchDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector);
diff --git a/frontend/src/Settings/General/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js
new file mode 100644
index 000000000..049265435
--- /dev/null
+++ b/frontend/src/Settings/General/AnalyticSettings.js
@@ -0,0 +1,42 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function AnalyticSettings(props) {
+ const {
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ analyticsEnabled
+ } = settings;
+
+ return (
+
+
+ Send Anonymous Usage Data
+
+
+
+
+ );
+}
+
+AnalyticSettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default AnalyticSettings;
diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js
new file mode 100644
index 000000000..c358ee511
--- /dev/null
+++ b/frontend/src/Settings/General/BackupSettings.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function BackupSettings(props) {
+ const {
+ advancedSettings,
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ backupFolder,
+ backupInterval,
+ backupRetention
+ } = settings;
+
+ if (!advancedSettings) {
+ return null;
+ }
+
+ return (
+
+
+ Folder
+
+
+
+
+
+ Interval
+
+
+
+
+
+ Retention
+
+
+
+
+ );
+}
+
+BackupSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default BackupSettings;
diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js
new file mode 100644
index 000000000..485d8a1be
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettings.js
@@ -0,0 +1,210 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import Form from 'Components/Form/Form';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import AnalyticSettings from './AnalyticSettings';
+import BackupSettings from './BackupSettings';
+import HostSettings from './HostSettings';
+import LoggingSettings from './LoggingSettings';
+import ProxySettings from './ProxySettings';
+import SecuritySettings from './SecuritySettings';
+import UpdateSettings from './UpdateSettings';
+
+class GeneralSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestartRequiredModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ settings,
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (isSaving || saveError || !prevProps.isSaving) {
+ return;
+ }
+
+ const prevSettings = prevProps.settings;
+
+ const keys = [
+ 'bindAddress',
+ 'port',
+ 'urlBase',
+ 'enableSsl',
+ 'sslPort',
+ 'sslCertHash',
+ 'authenticationMethod',
+ 'username',
+ 'password',
+ 'apiKey'
+ ];
+
+ const pendingRestart = _.some(keys, (key) => {
+ const setting = settings[key];
+ const prevSetting = prevSettings[key];
+
+ if (!setting || !prevSetting) {
+ return false;
+ }
+
+ const previousValue = prevSetting.previousValue;
+ const value = setting.value;
+
+ return previousValue != null && previousValue !== value;
+ });
+
+ this.setState({ isRestartRequiredModalOpen: pendingRestart });
+ }
+
+ //
+ // Listeners
+
+ onConfirmRestart = () => {
+ this.setState({ isRestartRequiredModalOpen: false });
+ this.props.onConfirmRestart();
+ }
+
+ onCloseRestartRequiredModalOpen = () => {
+ this.setState({ isRestartRequiredModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ isPopulated,
+ error,
+ settings,
+ hasSettings,
+ isResettingApiKey,
+ isMono,
+ isWindows,
+ mode,
+ onInputChange,
+ onConfirmResetApiKey,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load General settings
+ }
+
+ {
+ hasSettings && isPopulated && !error &&
+
+ }
+
+
+
+
+ );
+ }
+
+}
+
+GeneralSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ isResettingApiKey: PropTypes.bool.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ isWindows: PropTypes.bool.isRequired,
+ mode: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onConfirmResetApiKey: PropTypes.func.isRequired,
+ onConfirmRestart: PropTypes.func.isRequired
+};
+
+export default GeneralSettings;
diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js
new file mode 100644
index 000000000..804fdfde7
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettingsConnector.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { setGeneralSettingsValue, saveGeneralSettings, fetchGeneralSettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { restart } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import GeneralSettings from './GeneralSettings';
+
+const SECTION = 'general';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ createCommandExecutingSelector(commandNames.RESET_API_KEY),
+ createSystemStatusSelector(),
+ (advancedSettings, sectionSettings, isResettingApiKey, systemStatus) => {
+ return {
+ advancedSettings,
+ isResettingApiKey,
+ isMono: systemStatus.isMono,
+ isWindows: systemStatus.isWindows,
+ mode: systemStatus.mode,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setGeneralSettingsValue,
+ saveGeneralSettings,
+ fetchGeneralSettings,
+ executeCommand,
+ restart,
+ clearPendingChanges
+};
+
+class GeneralSettingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchGeneralSettings();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.isResettingApiKey && prevProps.isResettingApiKey) {
+ this.props.fetchGeneralSettings();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setGeneralSettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveGeneralSettings();
+ }
+
+ onConfirmResetApiKey = () => {
+ this.props.executeCommand({ name: commandNames.RESET_API_KEY });
+ }
+
+ onConfirmRestart = () => {
+ this.props.restart();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+GeneralSettingsConnector.propTypes = {
+ isResettingApiKey: PropTypes.bool.isRequired,
+ setGeneralSettingsValue: PropTypes.func.isRequired,
+ saveGeneralSettings: PropTypes.func.isRequired,
+ fetchGeneralSettings: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ restart: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(GeneralSettingsConnector);
diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js
new file mode 100644
index 000000000..2d17dc09f
--- /dev/null
+++ b/frontend/src/Settings/General/HostSettings.js
@@ -0,0 +1,154 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function HostSettings(props) {
+ const {
+ advancedSettings,
+ settings,
+ isWindows,
+ mode,
+ onInputChange
+ } = props;
+
+ const {
+ bindAddress,
+ port,
+ urlBase,
+ enableSsl,
+ sslPort,
+ sslCertHash,
+ launchBrowser
+ } = settings;
+
+ return (
+
+
+ Bind Address
+
+
+
+
+
+ Port Number
+
+
+
+
+
+ URL Base
+
+
+
+
+
+ Enable SSL
+
+
+
+
+ {
+ enableSsl.value &&
+
+ SSL Port
+
+
+
+ }
+
+ {
+ isWindows && enableSsl.value &&
+
+ SSL Cert Hash
+
+
+
+ }
+
+ {
+ mode !== 'service' &&
+
+ Open browser on start
+
+
+
+ }
+
+
+ );
+}
+
+HostSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+ isWindows: PropTypes.bool.isRequired,
+ mode: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default HostSettings;
diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js
new file mode 100644
index 000000000..e7853328e
--- /dev/null
+++ b/frontend/src/Settings/General/LoggingSettings.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function LoggingSettings(props) {
+ const {
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ logLevel
+ } = settings;
+
+ const logLevelOptions = [
+ { key: 'info', value: 'Info' },
+ { key: 'debug', value: 'Debug' },
+ { key: 'trace', value: 'Trace' }
+ ];
+
+ return (
+
+
+ Log Level
+
+
+
+
+ );
+}
+
+LoggingSettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default LoggingSettings;
diff --git a/frontend/src/Settings/General/ProxySettings.js b/frontend/src/Settings/General/ProxySettings.js
new file mode 100644
index 000000000..238cf3a30
--- /dev/null
+++ b/frontend/src/Settings/General/ProxySettings.js
@@ -0,0 +1,142 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function ProxySettings(props) {
+ const {
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ proxyEnabled,
+ proxyType,
+ proxyHostname,
+ proxyPort,
+ proxyUsername,
+ proxyPassword,
+ proxyBypassFilter,
+ proxyBypassLocalAddresses
+ } = settings;
+
+ const proxyTypeOptions = [
+ { key: 'http', value: 'HTTP(S)' },
+ { key: 'socks4', value: 'Socks4' },
+ { key: 'socks5', value: 'Socks5 (Support TOR)' }
+ ];
+
+ return (
+
+
+ Use Proxy
+
+
+
+
+ {
+ proxyEnabled.value &&
+
+
+ Proxy Type
+
+
+
+
+
+ Hostname
+
+
+
+
+
+ Port
+
+
+
+
+
+ Username
+
+
+
+
+
+ Password
+
+
+
+
+
+ Ignored Addresses
+
+
+
+
+
+ Bypass Proxy for Local Addresses
+
+
+
+
+ }
+
+ );
+}
+
+ProxySettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default ProxySettings;
diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js
new file mode 100644
index 000000000..42c77a46f
--- /dev/null
+++ b/frontend/src/Settings/General/SecuritySettings.js
@@ -0,0 +1,170 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import ClipboardButton from 'Components/Link/ClipboardButton';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormInputButton from 'Components/Form/FormInputButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+
+class SecuritySettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isConfirmApiKeyResetModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onApikeyFocus = (event) => {
+ event.target.select();
+ }
+
+ onResetApiKeyPress = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: true });
+ }
+
+ onConfirmResetApiKey = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ this.props.onConfirmResetApiKey();
+ }
+
+ onCloseResetApiKeyModal = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ settings,
+ isResettingApiKey,
+ onInputChange
+ } = this.props;
+
+ const {
+ authenticationMethod,
+ username,
+ password,
+ apiKey
+ } = settings;
+
+ const authenticationMethodOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'basic', value: 'Basic (Browser Popup)' },
+ { key: 'forms', value: 'Forms (Login Page)' }
+ ];
+
+ const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
+
+ return (
+
+
+ Authentication
+
+
+
+
+ {
+ authenticationEnabled &&
+
+ Username
+
+
+
+ }
+
+ {
+ authenticationEnabled &&
+
+ Password
+
+
+
+ }
+
+
+ API Key
+
+ ,
+
+
+
+
+ ]}
+ onChange={onInputChange}
+ onFocus={this.onApikeyFocus}
+ {...apiKey}
+ />
+
+
+
+
+ );
+ }
+}
+
+SecuritySettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ isResettingApiKey: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onConfirmResetApiKey: PropTypes.func.isRequired
+};
+
+export default SecuritySettings;
diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js
new file mode 100644
index 000000000..8a4a549f8
--- /dev/null
+++ b/frontend/src/Settings/General/UpdateSettings.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function UpdateSettings(props) {
+ const {
+ advancedSettings,
+ settings,
+ isMono,
+ onInputChange
+ } = props;
+
+ const {
+ branch,
+ updateAutomatically,
+ updateMechanism,
+ updateScriptPath
+ } = settings;
+
+ if (!advancedSettings) {
+ return null;
+ }
+
+ const updateOptions = [
+ { key: 'builtIn', value: 'Built-In' },
+ { key: 'script', value: 'Script' }
+ ];
+
+ return (
+
+
+ Branch
+
+
+
+
+ {
+ isMono &&
+
+
+ Automatic
+
+
+
+
+
+ Mechanism
+
+
+
+
+ {
+ updateMechanism.value === 'script' &&
+
+ Script Path
+
+
+
+ }
+
+ }
+
+ );
+}
+
+UpdateSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default UpdateSettings;
diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js
new file mode 100644
index 000000000..2a8993c92
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettings.js
@@ -0,0 +1,97 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import IndexersConnector from './Indexers/IndexersConnector';
+import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
+
+class IndexerSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ // Render
+ //
+
+ render() {
+ const {
+ isTestingAll,
+ dispatchTestAllIndexers
+ } = this.props;
+
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+
+ );
+ }
+}
+
+IndexerSettings.propTypes = {
+ isTestingAll: PropTypes.bool.isRequired,
+ dispatchTestAllIndexers: PropTypes.func.isRequired
+};
+
+export default IndexerSettings;
diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js
new file mode 100644
index 000000000..1eaf098d7
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllIndexers } from 'Store/Actions/settingsActions';
+import IndexerSettings from './IndexerSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers.isTestingAll,
+ (isTestingAll) => {
+ return {
+ isTestingAll
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchTestAllIndexers: testAllIndexers
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSettings);
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css
new file mode 100644
index 000000000..d228b842b
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css
@@ -0,0 +1,44 @@
+.indexer {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js
new file mode 100644
index 000000000..21db4ecf1
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem';
+import styles from './AddIndexerItem.css';
+
+class AddIndexerItem extends Component {
+
+ //
+ // Listeners
+
+ onIndexerSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onIndexerSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onIndexerSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddIndexerItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onIndexerSelect: PropTypes.func.isRequired
+};
+
+export default AddIndexerItem;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js
new file mode 100644
index 000000000..d05e8eb9a
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddIndexerModalContentConnector from './AddIndexerModalContentConnector';
+
+function AddIndexerModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddIndexerModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddIndexerModal;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css
new file mode 100644
index 000000000..946305dff
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css
@@ -0,0 +1,5 @@
+.indexers {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js
new file mode 100644
index 000000000..5364bd9dd
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 AddIndexerItem from './AddIndexerItem';
+import styles from './AddIndexerModalContent.css';
+
+class AddIndexerModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetIndexers,
+ torrentIndexers,
+ onIndexerSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Indexer
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+
+ Sonarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
+ For more information on the individual indexers, clink on the info buttons.
+
+
+
+
+ {
+ usenetIndexers.map((indexer) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ torrentIndexers.map((indexer) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddIndexerModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
+ torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onIndexerSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddIndexerModalContent;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js
new file mode 100644
index 000000000..d79f028da
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions';
+import AddIndexerModalContent from './AddIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (indexers) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = indexers;
+
+ const usenetIndexers = _.filter(schema, { protocol: 'usenet' });
+ const torrentIndexers = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetIndexers,
+ torrentIndexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexerSchema,
+ selectIndexerSchema
+};
+
+class AddIndexerModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexerSchema();
+ }
+
+ //
+ // Listeners
+
+ onIndexerSelect = ({ implementation, name }) => {
+ this.props.selectIndexerSchema({ implementation, presetName: name });
+ this.props.onModalClose({ indexerSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddIndexerModalContentConnector.propTypes = {
+ fetchIndexerSchema: PropTypes.func.isRequired,
+ selectIndexerSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js
new file mode 100644
index 000000000..ddea8b043
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddIndexerPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddIndexerPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddIndexerPresetMenuItem;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js
new file mode 100644
index 000000000..d7401b95f
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditIndexerModalContentConnector from './EditIndexerModalContentConnector';
+
+function EditIndexerModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditIndexerModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditIndexerModal;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js
new file mode 100644
index 000000000..ec0b7586e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelTestIndexer, cancelSaveIndexer } from 'Store/Actions/settingsActions';
+import EditIndexerModal from './EditIndexerModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.indexers';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestIndexer() {
+ dispatch(cancelTestIndexer({ section }));
+ },
+
+ dispatchCancelSaveIndexer() {
+ dispatch(cancelSaveIndexer({ section }));
+ }
+ };
+}
+
+class EditIndexerModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestIndexer();
+ this.props.dispatchCancelSaveIndexer();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestIndexer,
+ dispatchCancelSaveIndexer,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditIndexerModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestIndexer: PropTypes.func.isRequired,
+ dispatchCancelSaveIndexer: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditIndexerModalConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js
new file mode 100644
index 000000000..6a250df61
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js
@@ -0,0 +1,194 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditIndexerModalContent.css';
+
+function EditIndexerModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteIndexerPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ enableRss,
+ enableAutomaticSearch,
+ enableInteractiveSearch,
+ supportsRss,
+ supportsSearch,
+ fields
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Indexer - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditIndexerModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteIndexerPress: PropTypes.func
+};
+
+export default EditIndexerModalContent;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
new file mode 100644
index 000000000..f993d2796
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions';
+import EditIndexerModalContent from './EditIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('indexers'),
+ (advancedSettings, indexer) => {
+ return {
+ advancedSettings,
+ ...indexer
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setIndexerValue,
+ setIndexerFieldValue,
+ saveIndexer,
+ testIndexer
+};
+
+class EditIndexerModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setIndexerValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setIndexerFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveIndexer({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testIndexer({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditIndexerModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setIndexerValue: PropTypes.func.isRequired,
+ setIndexerFieldValue: PropTypes.func.isRequired,
+ saveIndexer: PropTypes.func.isRequired,
+ testIndexer: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.css b/frontend/src/Settings/Indexers/Indexers/Indexer.css
new file mode 100644
index 000000000..d8e1a731e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.css
@@ -0,0 +1,19 @@
+.indexer {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js
new file mode 100644
index 000000000..9269f8532
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js
@@ -0,0 +1,140 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditIndexerModalConnector from './EditIndexerModalConnector';
+import styles from './Indexer.css';
+
+class Indexer extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditIndexerModalOpen: false,
+ isDeleteIndexerModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditIndexerPress = () => {
+ this.setState({ isEditIndexerModalOpen: true });
+ }
+
+ onEditIndexerModalClose = () => {
+ this.setState({ isEditIndexerModalOpen: false });
+ }
+
+ onDeleteIndexerPress = () => {
+ this.setState({
+ isEditIndexerModalOpen: false,
+ isDeleteIndexerModalOpen: true
+ });
+ }
+
+ onDeleteIndexerModalClose= () => {
+ this.setState({ isDeleteIndexerModalOpen: false });
+ }
+
+ onConfirmDeleteIndexer = () => {
+ this.props.onConfirmDeleteIndexer(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enableRss,
+ enableAutomaticSearch,
+ enableInteractiveSearch,
+ supportsRss,
+ supportsSearch
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ {
+ supportsRss && enableRss &&
+
+ RSS
+
+ }
+
+ {
+ supportsSearch && enableAutomaticSearch &&
+
+ Automatic Search
+
+ }
+
+ {
+ supportsSearch && enableInteractiveSearch &&
+
+ Interactive Search
+
+ }
+
+ {
+ !enableRss && !enableAutomaticSearch && !enableInteractiveSearch &&
+
+ Disabled
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+Indexer.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enableRss: PropTypes.bool.isRequired,
+ enableAutomaticSearch: PropTypes.bool.isRequired,
+ enableInteractiveSearch: PropTypes.bool.isRequired,
+ supportsRss: PropTypes.bool.isRequired,
+ supportsSearch: PropTypes.bool.isRequired,
+ onConfirmDeleteIndexer: PropTypes.func.isRequired
+};
+
+export default Indexer;
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.css b/frontend/src/Settings/Indexers/Indexers/Indexers.css
new file mode 100644
index 000000000..ec8cb2891
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexers.css
@@ -0,0 +1,20 @@
+.indexers {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addIndexer {
+ composes: indexer from './Indexer.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.js b/frontend/src/Settings/Indexers/Indexers/Indexers.js
new file mode 100644
index 000000000..f5fea9aac
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Indexer from './Indexer';
+import AddIndexerModal from './AddIndexerModal';
+import EditIndexerModalConnector from './EditIndexerModalConnector';
+import styles from './Indexers.css';
+
+class Indexers extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddIndexerModalOpen: false,
+ isEditIndexerModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddIndexerPress = () => {
+ this.setState({ isAddIndexerModalOpen: true });
+ }
+
+ onAddIndexerModalClose = ({ indexerSelected = false } = {}) => {
+ this.setState({
+ isAddIndexerModalOpen: false,
+ isEditIndexerModalOpen: indexerSelected
+ });
+ }
+
+ onEditIndexerModalClose = () => {
+ this.setState({ isEditIndexerModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteIndexer,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddIndexerModalOpen,
+ isEditIndexerModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Indexers.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteIndexer: PropTypes.func.isRequired
+};
+
+export default Indexers;
diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js
new file mode 100644
index 000000000..415dae32b
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexers, deleteIndexer } from 'Store/Actions/settingsActions';
+import Indexers from './Indexers';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (indexers) => {
+ return {
+ ...indexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexers,
+ deleteIndexer
+};
+
+class IndexersConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexers();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteIndexer = (id) => {
+ this.props.deleteIndexer({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexersConnector.propTypes = {
+ fetchIndexers: PropTypes.func.isRequired,
+ deleteIndexer: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexersConnector);
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
new file mode 100644
index 000000000..30e2c39bc
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
@@ -0,0 +1,112 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function IndexerOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load indexer options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+}
+
+IndexerOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default IndexerOptions;
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
new file mode 100644
index 000000000..28d07c77e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchIndexerOptions, setIndexerOptionsValue, saveIndexerOptions } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import IndexerOptions from './IndexerOptions';
+
+const SECTION = 'indexerOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchIndexerOptions: fetchIndexerOptions,
+ dispatchSetIndexerOptionsValue: setIndexerOptionsValue,
+ dispatchSaveIndexerOptions: saveIndexerOptions,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class IndexerOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchIndexerOptions,
+ dispatchSaveIndexerOptions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchIndexerOptions();
+ onChildMounted(dispatchSaveIndexerOptions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetIndexerOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexerOptionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchIndexerOptions: PropTypes.func.isRequired,
+ dispatchSetIndexerOptionsValue: PropTypes.func.isRequired,
+ dispatchSaveIndexerOptions: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerOptionsConnector);
diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js
new file mode 100644
index 000000000..9e01323c7
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -0,0 +1,384 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import NamingConnector from './Naming/NamingConnector';
+
+const rescanAfterRefreshOptions = [
+ { key: 'always', value: 'Always' },
+ { key: 'afterManual', value: 'After Manual Refresh' },
+ { key: 'never', value: 'Never' }
+];
+
+const fileDateOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'localAirDate', value: 'Local Air Date' },
+ { key: 'utcAirDate', value: 'UTC Air Date' }
+];
+
+class MediaManagement extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ isMono,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load Media Management settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+ }
+
+}
+
+MediaManagement.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default MediaManagement;
diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
new file mode 100644
index 000000000..d6ad03ad2
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
@@ -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 createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { fetchMediaManagementSettings, setMediaManagementSettingsValue, saveMediaManagementSettings, saveNamingSettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import MediaManagement from './MediaManagement';
+
+const SECTION = 'mediaManagement';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.naming,
+ createSettingsSectionSelector(SECTION),
+ createSystemStatusSelector(),
+ (advancedSettings, namingSettings, sectionSettings, systemStatus) => {
+ return {
+ advancedSettings,
+ ...sectionSettings,
+ hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges,
+ isMono: systemStatus.isMono
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMediaManagementSettings,
+ setMediaManagementSettingsValue,
+ saveMediaManagementSettings,
+ saveNamingSettings,
+ clearPendingChanges
+};
+
+class MediaManagementConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMediaManagementSettings();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMediaManagementSettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMediaManagementSettings();
+ this.props.saveNamingSettings();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MediaManagementConnector.propTypes = {
+ fetchMediaManagementSettings: PropTypes.func.isRequired,
+ setMediaManagementSettingsValue: PropTypes.func.isRequired,
+ saveMediaManagementSettings: PropTypes.func.isRequired,
+ saveNamingSettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css
new file mode 100644
index 000000000..da27e292e
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css
@@ -0,0 +1,5 @@
+.namingInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js
new file mode 100644
index 000000000..d38d86e0a
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js
@@ -0,0 +1,342 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FormInputButton from 'Components/Form/FormInputButton';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import NamingModal from './NamingModal';
+import styles from './Naming.css';
+
+class Naming extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isNamingModalOpen: false,
+ namingModalOptions: null
+ };
+ }
+
+ //
+ // Listeners
+
+ onStandardNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'standardEpisodeFormat',
+ season: true,
+ episode: true,
+ additional: true
+ }
+ });
+ }
+
+ onDailyNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'dailyEpisodeFormat',
+ season: true,
+ episode: true,
+ daily: true,
+ additional: true
+ }
+ });
+ }
+
+ onAnimeNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'animeEpisodeFormat',
+ season: true,
+ episode: true,
+ anime: true,
+ additional: true
+ }
+ });
+ }
+
+ onSeriesFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'seriesFolderFormat'
+ }
+ });
+ }
+
+ onSeasonFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'seasonFolderFormat',
+ season: true
+ }
+ });
+ }
+
+ onNamingModalClose = () => {
+ this.setState({ isNamingModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ examples,
+ examplesPopulated,
+ onInputChange
+ } = this.props;
+
+ const {
+ isNamingModalOpen,
+ namingModalOptions
+ } = this.state;
+
+ const renameEpisodes = hasSettings && settings.renameEpisodes.value;
+
+ const multiEpisodeStyleOptions = [
+ { key: 0, value: 'Extend' },
+ { key: 1, value: 'Duplicate' },
+ { key: 2, value: 'Repeat' },
+ { key: 3, value: 'Scene' },
+ { key: 4, value: 'Range' },
+ { key: 5, value: 'Prefixed Range' }
+ ];
+
+ const standardEpisodeFormatHelpTexts = [];
+ const standardEpisodeFormatErrors = [];
+ const dailyEpisodeFormatHelpTexts = [];
+ const dailyEpisodeFormatErrors = [];
+ const animeEpisodeFormatHelpTexts = [];
+ const animeEpisodeFormatErrors = [];
+ const seriesFolderFormatHelpTexts = [];
+ const seriesFolderFormatErrors = [];
+ const seasonFolderFormatHelpTexts = [];
+ const seasonFolderFormatErrors = [];
+
+ if (examplesPopulated) {
+ if (examples.singleEpisodeExample) {
+ standardEpisodeFormatHelpTexts.push(`Single Episode: ${examples.singleEpisodeExample}`);
+ } else {
+ standardEpisodeFormatErrors.push({ message: 'Single Episode: Invalid Format' });
+ }
+
+ if (examples.multiEpisodeExample) {
+ standardEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.multiEpisodeExample}`);
+ } else {
+ standardEpisodeFormatErrors.push({ message: 'Multi Episode: Invalid Format' });
+ }
+
+ if (examples.dailyEpisodeExample) {
+ dailyEpisodeFormatHelpTexts.push(`Example: ${examples.dailyEpisodeExample}`);
+ } else {
+ dailyEpisodeFormatErrors.push({ message: 'Invalid Format' });
+ }
+
+ if (examples.animeEpisodeExample) {
+ animeEpisodeFormatHelpTexts.push(`Single Episode: ${examples.animeEpisodeExample}`);
+ } else {
+ animeEpisodeFormatErrors.push({ message: 'Single Episode: Invalid Format' });
+ }
+
+ if (examples.animeMultiEpisodeExample) {
+ animeEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.animeMultiEpisodeExample}`);
+ } else {
+ animeEpisodeFormatErrors.push({ message: 'Multi Episode: Invalid Format' });
+ }
+
+ if (examples.seriesFolderExample) {
+ seriesFolderFormatHelpTexts.push(`Example: ${examples.seriesFolderExample}`);
+ } else {
+ seriesFolderFormatErrors.push({ message: 'Invalid Format' });
+ }
+
+ if (examples.seasonFolderExample) {
+ seasonFolderFormatHelpTexts.push(`Example: ${examples.seasonFolderExample}`);
+ } else {
+ seasonFolderFormatErrors.push({ message: 'Invalid Format' });
+ }
+ }
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load Naming settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+ }
+
+}
+
+Naming.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ examples: PropTypes.object.isRequired,
+ examplesPopulated: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default Naming;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
new file mode 100644
index 000000000..d8210317e
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
@@ -0,0 +1,97 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchNamingSettings, setNamingSettingsValue, fetchNamingExamples } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import Naming from './Naming';
+
+const SECTION = 'naming';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.namingExamples,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, examples, sectionSettings) => {
+ return {
+ advancedSettings,
+ examples: examples.item,
+ examplesPopulated: !_.isEmpty(examples.item),
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNamingSettings,
+ setNamingSettingsValue,
+ fetchNamingExamples,
+ clearPendingChanges
+};
+
+class NamingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._namingExampleTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchNamingSettings();
+ this.props.fetchNamingExamples();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Control
+
+ _fetchNamingExamples = () => {
+ this.props.fetchNamingExamples();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNamingSettingsValue({ name, value });
+
+ if (this._namingExampleTimeout) {
+ clearTimeout(this._namingExampleTimeout);
+ }
+
+ this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NamingConnector.propTypes = {
+ fetchNamingSettings: PropTypes.func.isRequired,
+ setNamingSettingsValue: PropTypes.func.isRequired,
+ fetchNamingExamples: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
new file mode 100644
index 000000000..de6a54e7f
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
@@ -0,0 +1,18 @@
+.groups {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+
+.namingSelectContainer {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.namingSelect {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ margin-left: 10px;
+ width: 200px;
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
new file mode 100644
index 000000000..b7579c675
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
@@ -0,0 +1,551 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Button from 'Components/Link/Button';
+import SelectInput from 'Components/Form/SelectInput';
+import TextInput from 'Components/Form/TextInput';
+import Modal from 'Components/Modal/Modal';
+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 NamingOption from './NamingOption';
+import styles from './NamingModal.css';
+
+const separatorOptions = [
+ { key: ' ', value: 'Space ( )' },
+ { key: '.', value: 'Period (.)' },
+ { key: '_', value: 'Underscore (_)' },
+ { key: '-', value: 'Dash (-)' }
+];
+
+const caseOptions = [
+ { key: 'title', value: 'Default Case' },
+ { key: 'lower', value: 'Lower Case' },
+ { key: 'upper', value: 'Upper Case' }
+];
+
+const fileNameTokens = [
+ {
+ token: '{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}',
+ example: 'Series Title (2010) - S01E01 - Episode Title HDTV-720p Proper'
+ },
+ {
+ token: '{Series Title} - {season:0}x{episode:00} - {Episode Title} {Quality Full}',
+ example: 'Series Title (2010) - 1x01 - Episode Title HDTV-720p Proper'
+ },
+ {
+ token: '{Series.Title}.S{season:00}E{episode:00}.{EpisodeClean.Title}.{Quality.Full}',
+ example: 'Series.Title.(2010).S01E01.Episode.Title.HDTV-720p'
+ }
+];
+
+const seriesTokens = [
+ { token: '{Series Title}', example: 'Series Title!' },
+ { token: '{Series CleanTitle}', example: 'Series Title' },
+ { token: '{Series CleanTitleYear}', example: 'Series Title 2010' },
+ { token: '{Series TitleThe}', example: 'Series Title, The' },
+ { token: '{Series TitleTheYear}', example: 'Series Title, The (2010)' },
+ { token: '{Series TitleYear}', example: 'Series Title (2010)' },
+ { token: '{Series TitleFirstCharacter}', example: 'S' }
+];
+
+const seriesIdTokens = [
+ { token: '{ImdbId}', example: 'tt12345' },
+ { token: '{TvdbId}', example: '12345' },
+ { token: '{TvMazeId}', example: '54321' }
+];
+
+const seasonTokens = [
+ { token: '{season:0}', example: '1' },
+ { token: '{season:00}', example: '01' }
+];
+
+const episodeTokens = [
+ { token: '{episode:0}', example: '1' },
+ { token: '{episode:00}', example: '01' }
+];
+
+const airDateTokens = [
+ { token: '{Air-Date}', example: '2016-03-20' },
+ { token: '{Air Date}', example: '2016 03 20' }
+];
+
+const absoluteTokens = [
+ { token: '{absolute:0}', example: '1' },
+ { token: '{absolute:00}', example: '01' },
+ { token: '{absolute:000}', example: '001' }
+];
+
+const episodeTitleTokens = [
+ { token: '{Episode Title}', example: 'Episode Title' },
+ { token: '{Episode CleanTitle}', example: 'Episode Title' }
+];
+
+const qualityTokens = [
+ { token: '{Quality Full}', example: 'HDTV 720p Proper' },
+ { token: '{Quality Title}', example: 'HDTV 720p' }
+];
+
+const mediaInfoTokens = [
+ { token: '{MediaInfo Simple}', example: 'x264 DTS' },
+ { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' },
+ { token: '{MediaInfo VideoCodec}', example: 'x264' },
+ { token: '{MediaInfo AudioFormat}', example: 'DTS' },
+ { token: '{MediaInfo AudioChannels}', example: '5.1' }
+];
+
+const otherTokens = [
+ { token: '{Release Group}', example: 'Rls Grp' },
+ { token: '{Preferred Words}', example: 'iNTERNAL' }
+];
+
+const originalTokens = [
+ { token: '{Original Title}', example: 'Series.Title.S01E01.HDTV.x264-EVOLVE' },
+ { token: '{Original Filename}', example: 'series.title.s01e01.hdtv.x264-EVOLVE' }
+];
+
+class NamingModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._selectionStart = null;
+ this._selectionEnd = null;
+
+ this.state = {
+ separator: ' ',
+ case: 'title'
+ };
+ }
+
+ //
+ // Listeners
+
+ onTokenSeparatorChange = (event) => {
+ this.setState({ separator: event.value });
+ }
+
+ onTokenCaseChange = (event) => {
+ this.setState({ case: event.value });
+ }
+
+ onInputSelectionChange = (selectionStart, selectionEnd) => {
+ this._selectionStart = selectionStart;
+ this._selectionEnd = selectionEnd;
+ }
+
+ onOptionPress = ({ isFullFilename, tokenValue }) => {
+ const {
+ name,
+ value,
+ onInputChange
+ } = this.props;
+
+ const selectionStart = this._selectionStart;
+ const selectionEnd = this._selectionEnd;
+
+ if (isFullFilename) {
+ onInputChange({ name, value: tokenValue });
+ } else if (selectionStart == null) {
+ onInputChange({
+ name,
+ value: `${value}${tokenValue}`
+ });
+ } else {
+ const start = value.substring(0, selectionStart);
+ const end = value.substring(selectionEnd);
+ const newValue = `${start}${tokenValue}${end}`;
+
+ onInputChange({ name, value: newValue });
+ this._selectionStart = newValue.length - 1;
+ this._selectionEnd = newValue.length - 1;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ value,
+ isOpen,
+ advancedSettings,
+ season,
+ episode,
+ daily,
+ anime,
+ additional,
+ onInputChange,
+ onModalClose
+ } = this.props;
+
+ const {
+ separator: tokenSeparator,
+ case: tokenCase
+ } = this.state;
+
+ return (
+
+
+
+ File Name Tokens
+
+
+
+
+
+
+
+
+
+ {
+ !advancedSettings &&
+
+
+ {
+ fileNameTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+
+
+ {
+ seriesTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ seriesIdTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ {
+ season &&
+
+
+ {
+ seasonTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+ {
+ episode &&
+
+
+
+ {
+ episodeTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ {
+ daily &&
+
+
+ {
+ airDateTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+ {
+ anime &&
+
+
+ {
+ absoluteTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+ }
+
+ {
+ additional &&
+
+
+
+ {
+ episodeTitleTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ qualityTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ mediaInfoTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ otherTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ originalTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ }
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+NamingModal.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ season: PropTypes.bool.isRequired,
+ episode: PropTypes.bool.isRequired,
+ daily: PropTypes.bool.isRequired,
+ anime: PropTypes.bool.isRequired,
+ additional: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+NamingModal.defaultProps = {
+ season: false,
+ episode: false,
+ daily: false,
+ anime: false,
+ additional: false
+};
+
+export default NamingModal;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
new file mode 100644
index 000000000..d9f865936
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
@@ -0,0 +1,69 @@
+.option {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ margin: 3px;
+ border: 1px solid $borderColor;
+
+ &:hover {
+ .token {
+ background-color: #ddd;
+ }
+
+ .example {
+ background-color: #ccc;
+ }
+ }
+}
+
+.small {
+ width: 460px;
+}
+
+.large {
+ width: 100%;
+}
+
+.token {
+ flex: 0 0 50%;
+ padding: 6px 16px;
+ background-color: #eee;
+ font-family: $monoSpaceFontFamily;
+}
+
+.example {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ flex: 0 0 50%;
+ padding: 6px 16px;
+ background-color: #ddd;
+}
+
+.lower {
+ text-transform: lowercase;
+}
+
+.upper {
+ text-transform: uppercase;
+}
+
+.isFullFilename {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .option.small {
+ width: 100%;
+ }
+}
+
+@media only screen and (max-width: $breakpointExtraSmall) {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
new file mode 100644
index 000000000..269266a5f
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { sizes } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import styles from './NamingOption.css';
+
+class NamingOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ token,
+ tokenSeparator,
+ tokenCase,
+ isFullFilename,
+ onPress
+ } = this.props;
+
+ let tokenValue = token;
+
+ tokenValue = tokenValue.replace(/ /g, tokenSeparator);
+
+ if (tokenCase === 'lower') {
+ tokenValue = token.toLowerCase();
+ } else if (tokenCase === 'upper') {
+ tokenValue = token.toUpperCase();
+ }
+
+ onPress({ isFullFilename, tokenValue });
+ }
+
+ //
+ // Render
+ render() {
+ const {
+ token,
+ tokenSeparator,
+ example,
+ tokenCase,
+ isFullFilename,
+ size
+ } = this.props;
+
+ return (
+
+
+ {token.replace(/ /g, tokenSeparator)}
+
+
+
+ {example.replace(/ /g, tokenSeparator)}
+
+
+ );
+ }
+}
+
+NamingOption.propTypes = {
+ token: PropTypes.string.isRequired,
+ example: PropTypes.string.isRequired,
+ tokenSeparator: PropTypes.string.isRequired,
+ tokenCase: PropTypes.string.isRequired,
+ isFullFilename: PropTypes.bool.isRequired,
+ size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
+ onPress: PropTypes.func.isRequired
+};
+
+NamingOption.defaultProps = {
+ size: sizes.SMALL,
+ isFullFilename: false
+};
+
+export default NamingOption;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
new file mode 100644
index 000000000..24c0237cd
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditMetadataModalContentConnector from './EditMetadataModalContentConnector';
+
+function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditMetadataModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditMetadataModal;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
new file mode 100644
index 000000000..1a38beb4a
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditMetadataModal from './EditMetadataModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.metadata';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ }
+ };
+}
+
+class EditMetadataModalConnector extends Component {
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges({ section: 'metadata' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js
new file mode 100644
index 000000000..1e7006068
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+
+function EditMetadataModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ ...otherProps
+ } = props;
+
+ const {
+ name,
+ enable,
+ fields
+ } = item;
+
+ return (
+
+
+ Edit {name.value} Metadata
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditMetadataModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteMetadataPress: PropTypes.func
+};
+
+export default EditMetadataModalContent;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
new file mode 100644
index 000000000..2cd7636a0
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setMetadataValue, setMetadataFieldValue, saveMetadata } from 'Store/Actions/settingsActions';
+import EditMetadataModalContent from './EditMetadataModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.metadata,
+ (id, metadata) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = metadata;
+
+ const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError);
+
+ return {
+ id,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setMetadataValue,
+ setMetadataFieldValue,
+ saveMetadata
+};
+
+class EditMetadataModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMetadataValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setMetadataFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMetadata({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setMetadataValue: PropTypes.func.isRequired,
+ setMetadataFieldValue: PropTypes.func.isRequired,
+ saveMetadata: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css
new file mode 100644
index 000000000..31507ee23
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css
@@ -0,0 +1,15 @@
+.metadata {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.section {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js
new file mode 100644
index 000000000..eba01463c
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js
@@ -0,0 +1,149 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import EditMetadataModalConnector from './EditMetadataModalConnector';
+import styles from './Metadata.css';
+
+class Metadata extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMetadataModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMetadataPress = () => {
+ this.setState({ isEditMetadataModalOpen: true });
+ }
+
+ onEditMetadataModalClose = () => {
+ this.setState({ isEditMetadataModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enable,
+ fields
+ } = this.props;
+
+ const metadataFields = [];
+ const imageFields = [];
+
+ fields.forEach((field) => {
+ if (field.section === 'metadata') {
+ metadataFields.push(field);
+ } else {
+ imageFields.push(field);
+ }
+ });
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ enable ?
+
+ Enabled
+ :
+
+ Disabled
+
+ }
+
+
+ {
+ enable && !!metadataFields.length &&
+
+
+ Metadata
+
+
+ {
+ metadataFields.map((field) => {
+ if (!field.value) {
+ return null;
+ }
+
+ return (
+
+ {field.label}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ enable && !!imageFields.length &&
+
+
+ Images
+
+
+ {
+ imageFields.map((field) => {
+ if (!field.value) {
+ return null;
+ }
+
+ return (
+
+ {field.label}
+
+ );
+ })
+ }
+
+ }
+
+
+
+ );
+ }
+}
+
+Metadata.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enable: PropTypes.bool.isRequired,
+ fields: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadata;
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
new file mode 100644
index 000000000..fb1bd6080
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
@@ -0,0 +1,4 @@
+.metadatas {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
new file mode 100644
index 000000000..8659c5799
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Metadata from './Metadata';
+import styles from './Metadatas.css';
+
+function Metadatas(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+}
+
+Metadatas.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadatas;
diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
new file mode 100644
index 000000000..fb7153950
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchMetadata } from 'Store/Actions/settingsActions';
+import Metadatas from './Metadatas';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.metadata,
+ (metadata) => {
+ return {
+ ...metadata
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMetadata
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMetadata();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ fetchMetadata: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js
new file mode 100644
index 000000000..001936ab7
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataSettings.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import MetadatasConnector from './Metadata/MetadatasConnector';
+
+function MetadataSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default MetadataSettings;
diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js
new file mode 100644
index 000000000..c9bed6501
--- /dev/null
+++ b/frontend/src/Settings/Notifications/NotificationSettings.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import NotificationsConnector from './Notifications/NotificationsConnector';
+
+function NotificationSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default NotificationSettings;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css
new file mode 100644
index 000000000..e2181150c
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css
@@ -0,0 +1,44 @@
+.notification {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js
new file mode 100644
index 000000000..6d90961b0
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem';
+import styles from './AddNotificationItem.css';
+
+class AddNotificationItem extends Component {
+
+ //
+ // Listeners
+
+ onNotificationSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onNotificationSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onNotificationSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddNotificationItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onNotificationSelect: PropTypes.func.isRequired
+};
+
+export default AddNotificationItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
new file mode 100644
index 000000000..45f5e14b6
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNotificationModalContentConnector from './AddNotificationModalContentConnector';
+
+function AddNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
new file mode 100644
index 000000000..8744e516c
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
@@ -0,0 +1,5 @@
+.notifications {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
new file mode 100644
index 000000000..e09342b98
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 AddNotificationItem from './AddNotificationItem';
+import styles from './AddNotificationModalContent.css';
+
+class AddNotificationModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema,
+ onNotificationSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Notification
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new notification, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+ {
+ schema.map((notification) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddNotificationModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ schema: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNotificationSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js
new file mode 100644
index 000000000..abeb5e2ac
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchNotificationSchema, selectNotificationSchema } from 'Store/Actions/settingsActions';
+import AddNotificationModalContent from './AddNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.notifications,
+ (notifications) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = notifications;
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotificationSchema,
+ selectNotificationSchema
+};
+
+class AddNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotificationSchema();
+ }
+
+ //
+ // Listeners
+
+ onNotificationSelect = ({ implementation, name }) => {
+ this.props.selectNotificationSchema({ implementation, presetName: name });
+ this.props.onModalClose({ notificationSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNotificationModalContentConnector.propTypes = {
+ fetchNotificationSchema: PropTypes.func.isRequired,
+ selectNotificationSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
new file mode 100644
index 000000000..e4df85b8a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddNotificationPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddNotificationPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddNotificationPresetMenuItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
new file mode 100644
index 000000000..27e41d062
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditNotificationModalContentConnector from './EditNotificationModalContentConnector';
+
+function EditNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js
new file mode 100644
index 000000000..e1452d142
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelTestNotification, cancelSaveNotification } from 'Store/Actions/settingsActions';
+import EditNotificationModal from './EditNotificationModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.notifications';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestNotification() {
+ dispatch(cancelTestNotification({ section }));
+ },
+
+ dispatchCancelSaveNotification() {
+ dispatch(cancelSaveNotification({ section }));
+ }
+ };
+}
+
+class EditNotificationModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestNotification();
+ this.props.dispatchCancelSaveNotification();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestNotification,
+ dispatchCancelSaveNotification,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestNotification: PropTypes.func.isRequired,
+ dispatchCancelSaveNotification: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditNotificationModalConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css
new file mode 100644
index 000000000..c73406b57
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css
@@ -0,0 +1,11 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.message {
+ composes: alert from 'Components/Alert.css';
+
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
new file mode 100644
index 000000000..9c1d199c5
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
@@ -0,0 +1,237 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditNotificationModalContent.css';
+
+function EditNotificationModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteNotificationPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ onGrab,
+ onDownload,
+ onUpgrade,
+ onRename,
+ supportsOnGrab,
+ supportsOnDownload,
+ supportsOnUpgrade,
+ supportsOnRename,
+ tags,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Connection - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new notification, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditNotificationModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteNotificationPress: PropTypes.func
+};
+
+export default EditNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
new file mode 100644
index 000000000..104f1897a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setNotificationValue, setNotificationFieldValue, saveNotification, testNotification } from 'Store/Actions/settingsActions';
+import EditNotificationModalContent from './EditNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('notifications'),
+ (advancedSettings, notification) => {
+ return {
+ advancedSettings,
+ ...notification
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setNotificationValue,
+ setNotificationFieldValue,
+ saveNotification,
+ testNotification
+};
+
+class EditNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNotificationValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setNotificationFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveNotification({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testNotification({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setNotificationValue: PropTypes.func.isRequired,
+ setNotificationFieldValue: PropTypes.func.isRequired,
+ saveNotification: PropTypes.func.isRequired,
+ testNotification: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css b/frontend/src/Settings/Notifications/Notifications/Notification.css
new file mode 100644
index 000000000..5a17a4c20
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.css
@@ -0,0 +1,19 @@
+.notification {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js
new file mode 100644
index 000000000..143e5491a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.js
@@ -0,0 +1,150 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import styles from './Notification.css';
+
+class Notification extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditNotificationPress = () => {
+ this.setState({ isEditNotificationModalOpen: true });
+ }
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ }
+
+ onDeleteNotificationPress = () => {
+ this.setState({
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: true
+ });
+ }
+
+ onDeleteNotificationModalClose= () => {
+ this.setState({ isDeleteNotificationModalOpen: false });
+ }
+
+ onConfirmDeleteNotification = () => {
+ this.props.onConfirmDeleteNotification(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ onGrab,
+ onDownload,
+ onUpgrade,
+ onRename,
+ supportsOnGrab,
+ supportsOnDownload,
+ supportsOnUpgrade,
+ supportsOnRename
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+ {
+ supportsOnGrab && onGrab &&
+
+ On Grab
+
+ }
+
+ {
+ supportsOnDownload && onDownload &&
+
+ On Download
+
+ }
+
+ {
+ supportsOnUpgrade && onDownload && onUpgrade &&
+
+ On Upgrade
+
+ }
+
+ {
+ supportsOnRename && onRename &&
+
+ On Rename
+
+ }
+
+ {
+ !onGrab && !onDownload && !onRename &&
+
+ Disabled
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Notification.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ onGrab: PropTypes.bool.isRequired,
+ onDownload: PropTypes.bool.isRequired,
+ onUpgrade: PropTypes.bool.isRequired,
+ onRename: PropTypes.bool.isRequired,
+ supportsOnGrab: PropTypes.bool.isRequired,
+ supportsOnDownload: PropTypes.bool.isRequired,
+ supportsOnUpgrade: PropTypes.bool.isRequired,
+ supportsOnRename: PropTypes.bool.isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notification;
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css
new file mode 100644
index 000000000..26b890e88
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css
@@ -0,0 +1,20 @@
+.notifications {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addNotification {
+ composes: notification from './Notification.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.js b/frontend/src/Settings/Notifications/Notifications/Notifications.js
new file mode 100644
index 000000000..0296c2ed4
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Notification from './Notification';
+import AddNotificationModal from './AddNotificationModal';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import styles from './Notifications.css';
+
+class Notifications extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddNotificationPress = () => {
+ this.setState({ isAddNotificationModalOpen: true });
+ }
+
+ onAddNotificationModalClose = ({ notificationSelected = false } = {}) => {
+ this.setState({
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: notificationSelected
+ });
+ }
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteNotification,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddNotificationModalOpen,
+ isEditNotificationModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Notifications.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notifications;
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
new file mode 100644
index 000000000..b2b5e5166
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchNotifications, deleteNotification } from 'Store/Actions/settingsActions';
+import Notifications from './Notifications';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.notifications,
+ (notifications) => {
+ return {
+ ...notifications
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotifications,
+ deleteNotification
+};
+
+class NotificationsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotifications();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteNotification = (id) => {
+ this.props.deleteNotification({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NotificationsConnector.propTypes = {
+ fetchNotifications: PropTypes.func.isRequired,
+ deleteNotification: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NotificationsConnector);
diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js
new file mode 100644
index 000000000..e3b14e228
--- /dev/null
+++ b/frontend/src/Settings/PendingChangesModal.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function PendingChangesModal(props) {
+ const {
+ isOpen,
+ onConfirm,
+ onCancel
+ } = props;
+
+ return (
+
+
+ Unsaved Changes
+
+
+ You have unsaved changes, are you sure you want to leave this page?
+
+
+
+
+ Stay and review changes
+
+
+
+ Discard changes and leave
+
+
+
+
+ );
+}
+
+PendingChangesModal.propTypes = {
+ className: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all),
+ onConfirm: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired
+};
+
+PendingChangesModal.defaultProps = {
+ kind: kinds.PRIMARY
+};
+
+export default PendingChangesModal;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
new file mode 100644
index 000000000..238742efd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
@@ -0,0 +1,40 @@
+.delayProfile {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid $borderColor;
+ line-height: 30px;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.actions {
+ display: flex;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.editButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
new file mode 100644
index 000000000..d72a06467
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
@@ -0,0 +1,172 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import titleCase from 'Utilities/String/titleCase';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import TagList from 'Components/TagList';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfile.css';
+
+function getDelay(enabled, delay) {
+ if (!enabled) {
+ return '-';
+ }
+
+ if (!delay) {
+ return 'No Delay';
+ }
+
+ if (delay === 1) {
+ return '1 Minute';
+ }
+
+ // TODO: use better units of time than just minutes
+ return `${delay} Minutes`;
+}
+
+class DelayProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditDelayProfilePress = () => {
+ this.setState({ isEditDelayProfileModalOpen: true });
+ }
+
+ onEditDelayProfileModalClose = () => {
+ this.setState({ isEditDelayProfileModalOpen: false });
+ }
+
+ onDeleteDelayProfilePress = () => {
+ this.setState({
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: true
+ });
+ }
+
+ onDeleteDelayProfileModalClose = () => {
+ this.setState({ isDeleteDelayProfileModalOpen: false });
+ }
+
+ onConfirmDeleteDelayProfile = () => {
+ this.props.onConfirmDeleteDelayProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ enableUsenet,
+ enableTorrent,
+ preferredProtocol,
+ usenetDelay,
+ torrentDelay,
+ tags,
+ tagList,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ let preferred = titleCase(preferredProtocol);
+
+ if (!enableUsenet) {
+ preferred = 'Only Torrent';
+ } else if (!enableTorrent) {
+ preferred = 'Only Usenet';
+ }
+
+ return (
+
+
{preferred}
+
{getDelay(enableUsenet, usenetDelay)}
+
{getDelay(enableTorrent, torrentDelay)}
+
+
+
+
+
+
+
+
+ {
+ id !== 1 &&
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ enableUsenet: PropTypes.bool.isRequired,
+ enableTorrent: PropTypes.bool.isRequired,
+ preferredProtocol: PropTypes.string.isRequired,
+ usenetDelay: PropTypes.number.isRequired,
+ torrentDelay: PropTypes.number.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+DelayProfile.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default DelayProfile;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
new file mode 100644
index 000000000..cc5a92830
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
@@ -0,0 +1,3 @@
+.dragPreview {
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
new file mode 100644
index 000000000..402ddcc13
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragPreview.css';
+
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class DelayProfileDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ width,
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== DELAY_PROFILE) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = width - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ width,
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfileDragPreview.propTypes = {
+ width: PropTypes.number.isRequired,
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(DelayProfileDragPreview);
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
new file mode 100644
index 000000000..835250678
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
@@ -0,0 +1,17 @@
+.delayProfileDragSource {
+ padding: 4px 0;
+}
+
+.delayProfilePlaceholder {
+ width: 100%;
+ height: 30px;
+ border-bottom: 1px dotted #aaa;
+}
+
+.delayProfilePlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.delayProfilePlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
new file mode 100644
index 000000000..5c1c565e0
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragSource.css';
+
+const delayProfileDragSource = {
+ beginDrag(item) {
+ return item;
+ },
+
+ endDrag(props, monitor, component) {
+ props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const delayProfileDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().order;
+ const hoverIndex = props.order;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ // When moving up, only trigger if drag position is above 50% and
+ // when moving down, only trigger if drag position is below 50%.
+ // If we're moving down the hoverIndex needs to be increased
+ // by one so it's ordered properly. Otherwise the hoverIndex will work.
+
+ if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex + 1);
+ } else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex);
+ }
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class DelayProfileDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ order,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ ...otherProps
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+DelayProfileDragSource.propTypes = {
+ id: PropTypes.number.isRequired,
+ order: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onDelayProfileDragMove: PropTypes.func.isRequired,
+ onDelayProfileDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ DELAY_PROFILE,
+ delayProfileDropTarget,
+ collectDropTarget
+)(DragSource(
+ DELAY_PROFILE,
+ delayProfileDragSource,
+ collectDragSource
+)(DelayProfileDragSource));
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
new file mode 100644
index 000000000..3cf3e9020
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
@@ -0,0 +1,27 @@
+.delayProfiles {
+ user-select: none;
+}
+
+.delayProfilesHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.tags {
+ flex: 1 0 auto;
+}
+
+.addDelayProfile {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.addButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
new file mode 100644
index 000000000..a745da9d4
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import Measure from 'Components/Measure';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import DelayProfileDragSource from './DelayProfileDragSource';
+import DelayProfileDragPreview from './DelayProfileDragPreview';
+import DelayProfile from './DelayProfile';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfiles.css';
+
+class DelayProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddDelayProfileModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddDelayProfilePress = () => {
+ this.setState({ isAddDelayProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddDelayProfileModalOpen: false });
+ }
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ defaultProfile,
+ items,
+ tagList,
+ dragIndex,
+ dropIndex,
+ onConfirmDeleteDelayProfile,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddDelayProfileModalOpen,
+ width
+ } = this.state;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex < dragIndex;
+ const isDraggingDown = isDragging && dropIndex > dragIndex;
+
+ return (
+
+
+
+
+
Protocol
+
Usenet Delay
+
Torrent Delay
+
Tags
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ {
+ defaultProfile &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+export default DelayProfiles;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
new file mode 100644
index 000000000..16fe5718c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
@@ -0,0 +1,105 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDelayProfiles, deleteDelayProfile, reorderDelayProfile } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import DelayProfiles from './DelayProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.delayProfiles,
+ createTagsSelector(),
+ (delayProfiles, tagList) => {
+ const defaultProfile = _.find(delayProfiles.items, { id: 1 });
+ const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']);
+
+ return {
+ defaultProfile,
+ ...delayProfiles,
+ items,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDelayProfiles,
+ deleteDelayProfile,
+ reorderDelayProfile
+};
+
+class DelayProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchDelayProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteDelayProfile = (id) => {
+ this.props.deleteDelayProfile({ id });
+ }
+
+ onDelayProfileDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onDelayProfileDragEnd = ({ id }, didDrop) => {
+ const {
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DelayProfilesConnector.propTypes = {
+ fetchDelayProfiles: PropTypes.func.isRequired,
+ deleteDelayProfile: PropTypes.func.isRequired,
+ reorderDelayProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
new file mode 100644
index 000000000..9444fd65e
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector';
+
+function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditDelayProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditDelayProfileModal;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
new file mode 100644
index 000000000..a1e8d2dcd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditDelayProfileModal from './EditDelayProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditDelayProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.delayProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
new file mode 100644
index 000000000..faa32ee32
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
@@ -0,0 +1,188 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Alert from 'Components/Alert';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditDelayProfileModalContent.css';
+
+function EditDelayProfileModalContent(props) {
+ const {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ protocol,
+ protocolOptions,
+ onInputChange,
+ onProtocolChange,
+ onSavePress,
+ onModalClose,
+ onDeleteDelayProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay,
+ tags
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Delay Profile' : 'Add Delay Profile'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new quality profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id && id > 1 &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+const delayProfileShape = {
+ enableUsenet: PropTypes.shape(boolSettingShape).isRequired,
+ enableTorrent: PropTypes.shape(boolSettingShape).isRequired,
+ usenetDelay: PropTypes.shape(numberSettingShape).isRequired,
+ torrentDelay: PropTypes.shape(numberSettingShape).isRequired,
+ order: PropTypes.shape(numberSettingShape),
+ tags: PropTypes.shape(tagSettingShape).isRequired
+};
+
+EditDelayProfileModalContent.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.shape(delayProfileShape).isRequired,
+ protocol: PropTypes.string.isRequired,
+ protocolOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onProtocolChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteDelayProfilePress: PropTypes.func
+};
+
+export default EditDelayProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js
new file mode 100644
index 000000000..5b7e036f5
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js
@@ -0,0 +1,178 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setDelayProfileValue, saveDelayProfile } from 'Store/Actions/settingsActions';
+import EditDelayProfileModalContent from './EditDelayProfileModalContent';
+
+const newDelayProfile = {
+ enableUsenet: true,
+ enableTorrent: true,
+ preferredProtocol: 'usenet',
+ usenetDelay: 0,
+ torrentDelay: 0,
+ tags: []
+};
+
+const protocolOptions = [
+ { key: 'preferUsenet', value: 'Prefer Usenet' },
+ { key: 'preferTorrent', value: 'Prefer Torrent' },
+ { key: 'onlyUsenet', value: 'Only Usenet' },
+ { key: 'onlyTorrent', value: 'Only Torrent' }
+];
+
+function createDelayProfileSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.delayProfiles,
+ (id, delayProfiles) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = delayProfiles;
+
+ const profile = id ? _.find(items, { id }) : newDelayProfile;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createDelayProfileSelector(),
+ (delayProfile) => {
+ const enableUsenet = delayProfile.item.enableUsenet.value;
+ const enableTorrent = delayProfile.item.enableTorrent.value;
+ const preferredProtocol = delayProfile.item.preferredProtocol.value;
+ let protocol = 'preferUsenet';
+
+ if (preferredProtocol === 'usenet') {
+ protocol = 'preferUsenet';
+ } else {
+ protocol = 'preferTorrent';
+ }
+
+ if (!enableUsenet) {
+ protocol = 'onlyTorrent';
+ }
+
+ if (!enableTorrent) {
+ protocol = 'onlyUsenet';
+ }
+
+ return {
+ protocol,
+ protocolOptions,
+ ...delayProfile
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDelayProfileValue,
+ saveDelayProfile
+};
+
+class EditDelayProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newDelayProfile).forEach((name) => {
+ this.props.setDelayProfileValue({
+ name,
+ value: newDelayProfile[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDelayProfileValue({ name, value });
+ }
+
+ onProtocolChange = ({ value }) => {
+ switch (value) {
+ case 'preferUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'preferTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ case 'onlyUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: false });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'onlyTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: false });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ default:
+ throw Error(`Unknown protocol option: ${value}`);
+ }
+ }
+
+ onSavePress = () => {
+ this.props.saveDelayProfile({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDelayProfileValue: PropTypes.func.isRequired,
+ saveDelayProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js
new file mode 100644
index 000000000..6a17fd1fc
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditLanguageProfileModalContentConnector from './EditLanguageProfileModalContentConnector';
+
+function EditLanguageProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditLanguageProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditLanguageProfileModal;
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js
new file mode 100644
index 000000000..8e112c9e1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditLanguageProfileModal from './EditLanguageProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditLanguageProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.languageProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditLanguageProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditLanguageProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css
new file mode 100644
index 000000000..74dd1c8b7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css
@@ -0,0 +1,3 @@
+.deleteButtonContainer {
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js
new file mode 100644
index 000000000..ec4247c74
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js
@@ -0,0 +1,167 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import LanguageProfileItems from './LanguageProfileItems';
+import styles from './EditLanguageProfileModalContent.css';
+
+function EditLanguageProfileModalContent(props) {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ languages,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteLanguageProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ languages: itemLanguages
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Language Profile' : 'Add Language Profile'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new language profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+
+ Delete
+
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditLanguageProfileModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onCutoffChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteLanguageProfilePress: PropTypes.func
+};
+
+export default EditLanguageProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js
new file mode 100644
index 000000000..3cad2bb3c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js
@@ -0,0 +1,189 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { fetchLanguageProfileSchema, setLanguageProfileValue, saveLanguageProfile } from 'Store/Actions/settingsActions';
+import EditLanguageProfileModalContent from './EditLanguageProfileModalContent';
+
+function createLanguagesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('languageProfiles'),
+ (languageProfile) => {
+ const languages = languageProfile.item.languages;
+ if (!languages || !languages.value) {
+ return [];
+ }
+
+ return _.reduceRight(languages.value, (result, { allowed, language }) => {
+ if (allowed) {
+ result.push({
+ key: language.id,
+ value: language.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector('languageProfiles'),
+ createLanguagesSelector(),
+ createProfileInUseSelector('languageProfileId'),
+ (languageProfile, languages, isInUse) => {
+ return {
+ languages,
+ ...languageProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLanguageProfileSchema,
+ setLanguageProfileValue,
+ saveLanguageProfile
+};
+
+class EditLanguageProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id && !this.props.isPopulated) {
+ this.props.fetchLanguageProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setLanguageProfileValue({ name, value });
+ }
+
+ onCutoffChange = ({ name, value }) => {
+ const id = parseInt(value);
+ const item = _.find(this.props.item.languages.value, (i) => i.language.id === id);
+
+ this.props.setLanguageProfileValue({ name, value: item.language });
+ }
+
+ onSavePress = () => {
+ this.props.saveLanguageProfile({ id: this.props.id });
+ }
+
+ onLanguageProfileItemAllowedChange = (id, allowed) => {
+ const languageProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(languageProfile.languages.value, (i) => i.language.id === id);
+ item.allowed = allowed;
+
+ this.props.setLanguageProfileValue({
+ name: 'languages',
+ value: languageProfile.languages.value
+ });
+
+ const cutoff = languageProfile.cutoff.value;
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !_.find(languageProfile.languages.value, (i) => i.language.id === cutoff.id).allowed) {
+ const firstAllowed = _.find(languageProfile.languages.value, { allowed: true });
+
+ this.props.setLanguageProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.language : null });
+ }
+ }
+
+ onLanguageProfileItemDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onLanguageProfileItemDragEnd = ({ id }, didDrop) => {
+ const {
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ const languageProfile = _.cloneDeep(this.props.item);
+
+ const languages = languageProfile.languages.value.splice(dragIndex, 1);
+ languageProfile.languages.value.splice(dropIndex, 0, languages[0]);
+
+ this.props.setLanguageProfileValue({
+ name: 'languages',
+ value: languageProfile.languages.value
+ });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.languages) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditLanguageProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setLanguageProfileValue: PropTypes.func.isRequired,
+ fetchLanguageProfileSchema: PropTypes.func.isRequired,
+ saveLanguageProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditLanguageProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.css b/frontend/src/Settings/Profiles/Language/LanguageProfile.css
new file mode 100644
index 000000000..2ad9adfe9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.css
@@ -0,0 +1,31 @@
+.languageProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.languages {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.js b/frontend/src/Settings/Profiles/Language/LanguageProfile.js
new file mode 100644
index 000000000..c9d042e45
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.js
@@ -0,0 +1,147 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector';
+import styles from './LanguageProfile.css';
+
+class LanguageProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditLanguageProfileModalOpen: false,
+ isDeleteLanguageProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditLanguageProfilePress = () => {
+ this.setState({ isEditLanguageProfileModalOpen: true });
+ }
+
+ onEditLanguageProfileModalClose = () => {
+ this.setState({ isEditLanguageProfileModalOpen: false });
+ }
+
+ onDeleteLanguageProfilePress = () => {
+ this.setState({
+ isEditLanguageProfileModalOpen: false,
+ isDeleteLanguageProfileModalOpen: true
+ });
+ }
+
+ onDeleteLanguageProfileModalClose = () => {
+ this.setState({ isDeleteLanguageProfileModalOpen: false });
+ }
+onCloneLanguageProfilePress
+ onConfirmDeleteLanguageProfile = () => {
+ this.props.onConfirmDeleteLanguageProfile(this.props.id);
+ }
+
+ onCloneLanguageProfilePress = () => {
+ const {
+ id,
+ onCloneLanguageProfilePress
+ } = this.props;
+
+ onCloneLanguageProfilePress(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ languages,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ languages.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ const isCutoff = upgradeAllowed && item.language.id === cutoff.id;
+
+ return (
+
+ {item.language.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ upgradeAllowed: PropTypes.bool.isRequired,
+ cutoff: PropTypes.object.isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteLanguageProfile: PropTypes.func.isRequired,
+ onCloneLanguageProfilePress: PropTypes.func.isRequired
+};
+
+export default LanguageProfile;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css
new file mode 100644
index 000000000..a10233929
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css
@@ -0,0 +1,44 @@
+.languageProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+}
+
+.checkContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 7px;
+ margin-left: 8px;
+}
+
+.languageName {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: 36px;
+ cursor: pointer;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js
new file mode 100644
index 000000000..2a3671268
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js
@@ -0,0 +1,83 @@
+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 CheckInput from 'Components/Form/CheckInput';
+import styles from './LanguageProfileItem.css';
+
+class LanguageProfileItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ languageId,
+ onLanguageProfileItemAllowedChange
+ } = this.props;
+
+ onLanguageProfileItemAllowedChange(languageId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+LanguageProfileItem.propTypes = {
+ languageId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onLanguageProfileItemAllowedChange: PropTypes.func
+};
+
+LanguageProfileItem.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default LanguageProfileItem;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js
new file mode 100644
index 000000000..ded14b39d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import LanguageProfileItem from './LanguageProfileItem';
+import styles from './LanguageProfileItemDragPreview.css';
+
+const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
+const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class LanguageProfileItemDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ const {
+ languageId,
+ name,
+ allowed,
+ sortIndex
+ } = item;
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfileItemDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(LanguageProfileItemDragPreview);
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css
new file mode 100644
index 000000000..f59379129
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css
@@ -0,0 +1,18 @@
+.languageProfileItemDragSource {
+ padding: 4px 0;
+}
+
+.languageProfileItemPlaceholder {
+ width: 100%;
+ height: 36px;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.languageProfileItemPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.languageProfileItemPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js
new file mode 100644
index 000000000..304363726
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js
@@ -0,0 +1,157 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import LanguageProfileItem from './LanguageProfileItem';
+import styles from './LanguageProfileItemDragSource.css';
+
+const languageProfileItemDragSource = {
+ beginDrag({ languageId, name, allowed, sortIndex }) {
+ return {
+ languageId,
+ name,
+ allowed,
+ sortIndex
+ };
+ },
+
+ endDrag(props, monitor, component) {
+ props.onLanguageProfileItemDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const languageProfileItemDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().sortIndex;
+ const hoverIndex = props.sortIndex;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ // Moving up, only trigger if drag position is above 50%
+ if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
+ return;
+ }
+
+ // Moving down, only trigger if drag position is below 50%
+ if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
+ return;
+ }
+
+ props.onLanguageProfileItemDragMove(dragIndex, hoverIndex);
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class LanguageProfileItemDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ languageId,
+ name,
+ allowed,
+ sortIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ onLanguageProfileItemAllowedChange
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+LanguageProfileItemDragSource.propTypes = {
+ languageId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onLanguageProfileItemAllowedChange: PropTypes.func.isRequired,
+ onLanguageProfileItemDragMove: PropTypes.func.isRequired,
+ onLanguageProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ QUALITY_PROFILE_ITEM,
+ languageProfileItemDropTarget,
+ collectDropTarget
+)(DragSource(
+ QUALITY_PROFILE_ITEM,
+ languageProfileItemDragSource,
+ collectDragSource
+)(LanguageProfileItemDragSource));
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css
new file mode 100644
index 000000000..48b30f326
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css
@@ -0,0 +1,6 @@
+.languages {
+ margin-top: 10px;
+ /* TODO: This should consider the number of languages in the list */
+ min-height: 550px;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js
new file mode 100644
index 000000000..831743cbe
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import LanguageProfileItemDragSource from './LanguageProfileItemDragSource';
+import LanguageProfileItemDragPreview from './LanguageProfileItemDragPreview';
+import styles from './LanguageProfileItems.css';
+
+class LanguageProfileItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ dragIndex,
+ dropIndex,
+ languageProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex > dragIndex;
+ const isDraggingDown = isDragging && dropIndex < dragIndex;
+
+ return (
+
+ Languages
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ languageProfileItems.map(({ allowed, language }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfileItems.propTypes = {
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ languageProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object)
+};
+
+LanguageProfileItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default LanguageProfileItems;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js
new file mode 100644
index 000000000..61a7153b5
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ createLanguageProfileSelector(),
+ (languageProfile) => {
+ return {
+ name: languageProfile.name
+ };
+ }
+ );
+}
+
+function LanguageProfileNameConnector({ name, ...otherProps }) {
+ return (
+
+ {name}
+
+ );
+}
+
+LanguageProfileNameConnector.propTypes = {
+ languageProfileId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(LanguageProfileNameConnector);
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.css b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css
new file mode 100644
index 000000000..5a2fb73bd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css
@@ -0,0 +1,21 @@
+.languageProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addLanguageProfile {
+ composes: languageProfile from './LanguageProfile.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.js b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js
new file mode 100644
index 000000000..eb8286dfc
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js
@@ -0,0 +1,108 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import LanguageProfile from './LanguageProfile';
+import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector';
+import styles from './LanguageProfiles.css';
+
+class LanguageProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isLanguageProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCloneLanguageProfilePress = (id) => {
+ this.props.onCloneLanguageProfilePress(id);
+ this.setState({ isLanguageProfileModalOpen: true });
+ }
+
+ onEditLanguageProfilePress = () => {
+ this.setState({ isLanguageProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isLanguageProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteLanguageProfile,
+ onCloneLanguageProfilePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfiles.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteLanguageProfile: PropTypes.func.isRequired,
+ onCloneLanguageProfilePress: PropTypes.func.isRequired
+};
+
+export default LanguageProfiles;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js
new file mode 100644
index 000000000..1b122ab0f
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchLanguageProfiles, deleteLanguageProfile, cloneLanguageProfile } from 'Store/Actions/settingsActions';
+import LanguageProfiles from './LanguageProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.languageProfiles,
+ (advancedSettings, languageProfiles) => {
+ return {
+ advancedSettings,
+ ...languageProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchLanguageProfiles: fetchLanguageProfiles,
+ dispatchDeleteLanguageProfile: deleteLanguageProfile,
+ dispatchCloneLanguageProfile: cloneLanguageProfile
+};
+
+class LanguageProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchLanguageProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteLanguageProfile = (id) => {
+ this.props.dispatchDeleteLanguageProfile({ id });
+ }
+
+ onCloneLanguageProfilePress = (id) => {
+ this.props.dispatchCloneLanguageProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LanguageProfilesConnector.propTypes = {
+ dispatchFetchLanguageProfiles: PropTypes.func.isRequired,
+ dispatchDeleteLanguageProfile: PropTypes.func.isRequired,
+ dispatchCloneLanguageProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LanguageProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js
new file mode 100644
index 000000000..9f0130f27
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Profiles.js
@@ -0,0 +1,38 @@
+import React, { Component } from 'react';
+import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import QualityProfilesConnector from './Quality/QualityProfilesConnector';
+import LanguageProfilesConnector from './Language/LanguageProfilesConnector';
+import DelayProfilesConnector from './Delay/DelayProfilesConnector';
+import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector';
+
+class Profiles extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+// Only a single DragDropContext can exist so it's done here to allow editing
+// quality profiles and reordering delay profiles to work.
+
+export default DragDropContext(HTML5Backend)(Profiles);
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
new file mode 100644
index 000000000..9ecbd1ca8
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
+
+class EditQualityProfileModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ height: 'auto'
+ };
+ }
+
+ //
+ // Listeners
+
+ onContentHeightChange = (height) => {
+ if (this.state.height === 'auto' || height > this.state.height) {
+ this.setState({ height });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+EditQualityProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditQualityProfileModal;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
new file mode 100644
index 000000000..942949cac
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditQualityProfileModal from './EditQualityProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditQualityProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.qualityProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
new file mode 100644
index 000000000..2f6589933
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
@@ -0,0 +1,18 @@
+.formGroupsContainer {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.formGroupWrapper {
+ flex: 0 0 calc($formGroupSmallWidth - 100px);
+}
+
+.deleteButtonContainer {
+ margin-right: auto;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .formGroupsContainer {
+ display: block;
+ }
+}
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
new file mode 100644
index 000000000..7991f1a7e
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
@@ -0,0 +1,270 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Measure from 'Components/Measure';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import QualityProfileItems from './QualityProfileItems';
+import styles from './EditQualityProfileModalContent.css';
+
+const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
+
+class EditQualityProfileModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ headerHeight: 0,
+ bodyHeight: 0,
+ footerHeight: 0
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ headerHeight,
+ bodyHeight,
+ footerHeight
+ } = this.state;
+
+ if (
+ headerHeight > 0 &&
+ bodyHeight > 0 &&
+ footerHeight > 0 &&
+ (
+ headerHeight !== prevState.headerHeight ||
+ bodyHeight !== prevState.bodyHeight ||
+ footerHeight !== prevState.footerHeight
+ )
+ ) {
+ const padding = MODAL_BODY_PADDING * 2;
+
+ this.props.onContentHeightChange(
+ headerHeight + bodyHeight + footerHeight + padding
+ );
+ }
+ }
+
+ //
+ // Listeners
+
+ onHeaderMeasure = ({ height }) => {
+ if (height > this.state.headerHeight) {
+ this.setState({ headerHeight: height });
+ }
+ }
+
+ onBodyMeasure = ({ height }) => {
+
+ if (height > this.state.bodyHeight) {
+ this.setState({ bodyHeight: height });
+ }
+ }
+
+ onFooterMeasure = ({ height }) => {
+ if (height > this.state.footerHeight) {
+ this.setState({ footerHeight: height });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ qualities,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteQualityProfilePress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ items
+ } = item;
+
+ return (
+
+
+
+ {id ? 'Edit Quality Profile' : 'Add Quality Profile'}
+
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
Unable to add a new quality profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+
+ }
+
+
+
+
+
+
+ {
+ id &&
+
+
+ Delete
+
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+ );
+ }
+}
+
+EditQualityProfileModalContent.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onCutoffChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onContentHeightChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteQualityProfilePress: PropTypes.func
+};
+
+export default EditQualityProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
new file mode 100644
index 000000000..2decf2198
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
@@ -0,0 +1,442 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions';
+import EditQualityProfileModalContent from './EditQualityProfileModalContent';
+
+function getQualityItemGroupId(qualityProfile) {
+ // Get items with an `id` and filter out null/undefined values
+ const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
+
+ return Math.max(1000, ...ids) + 1;
+}
+
+function parseIndex(index) {
+ const split = index.split('.');
+
+ if (split.length === 1) {
+ return [
+ null,
+ parseInt(split[0]) - 1
+ ];
+ }
+
+ return [
+ parseInt(split[0]) - 1,
+ parseInt(split[1]) - 1
+ ];
+}
+
+function createQualitiesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ (qualityProfile) => {
+ const items = qualityProfile.item.items;
+ if (!items || !items.value) {
+ return [];
+ }
+
+ return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
+ if (allowed) {
+ if (id) {
+ result.push({
+ key: id,
+ value: name
+ });
+ } else {
+ result.push({
+ key: quality.id,
+ value: quality.name
+ });
+ }
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ createQualitiesSelector(),
+ createProfileInUseSelector('qualityProfileId'),
+ (qualityProfile, qualities, isInUse) => {
+ return {
+ qualities,
+ ...qualityProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ setQualityProfileValue,
+ saveQualityProfile
+};
+
+class EditQualityProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null,
+ editGroups: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id && !this.props.isPopulated) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Control
+
+ ensureCutoff = (qualityProfile) => {
+ const cutoff = qualityProfile.cutoff.value;
+
+ const cutoffItem = _.find(qualityProfile.items.value, (i) => {
+ if (!cutoff) {
+ return false;
+ }
+
+ return i.id === cutoff || (i.quality && i.quality.id === cutoff);
+ });
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
+ const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
+ let cutoffId = null;
+
+ if (firstAllowed) {
+ cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
+ }
+
+ this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setQualityProfileValue({ name, value });
+ }
+
+ onCutoffChange = ({ name, value }) => {
+ const id = parseInt(value);
+ const item = _.find(this.props.item.items.value, (i) => {
+ if (i.quality) {
+ return i.quality.id === id;
+ }
+
+ return i.id === id;
+ });
+
+ const cutoffId = item.quality ? item.quality.id : item.id;
+
+ this.props.setQualityProfileValue({ name, value: cutoffId });
+ }
+
+ onSavePress = () => {
+ this.props.saveQualityProfile({ id: this.props.id });
+ }
+
+ onQualityProfileItemAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
+
+ item.allowed = allowed;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onItemGroupAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.id === id);
+
+ item.allowed = allowed;
+
+ // Update each item in the group (for consistency only)
+ item.items.forEach((i) => {
+ i.allowed = allowed;
+ });
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onItemGroupNameChange = (id, name) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+
+ group.name = name;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+ }
+
+ onCreateGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(items, (i) => i.quality && i.quality.id === id);
+ const index = items.indexOf(item);
+ const groupId = getQualityItemGroupId(qualityProfile);
+
+ const group = {
+ id: groupId,
+ name: item.quality.name,
+ allowed: item.allowed,
+ items: [
+ item
+ ]
+ };
+
+ // Add the group in the same location the quality item was in.
+ items.splice(index, 1, group);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onDeleteGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+ const index = items.indexOf(group);
+
+ // Add the items in the same location the group was in
+ items.splice(index, 1, ...group.items);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onQualityProfileItemDragMove = (options) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex,
+ dropPosition
+ } = options;
+
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ if (
+ (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
+ (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
+ ) {
+ if (
+ this.state.dragQualityIndex != null &&
+ this.state.dropQualityIndex != null &&
+ this.state.dropPosition != null
+ ) {
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ }
+
+ return;
+ }
+
+ let adjustedDropQualityIndex = dropQualityIndex;
+
+ // Correct dragging out of a group to the position above
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex != null
+ ) {
+ // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting above outside a group
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex == null
+ ) {
+ // Add 2 to the item index so it's entered in the correct place
+ adjustedDropQualityIndex = `${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting below a quality within the same group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex != null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Add 1 to the group index leave the item index
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
+ }
+
+ // Correct inserting below a quality outside a group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex == null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Leave the item index so it's inserted below the item
+ adjustedDropQualityIndex = `${dropItemIndex}`;
+ }
+
+ if (
+ dragQualityIndex !== this.state.dragQualityIndex ||
+ adjustedDropQualityIndex !== this.state.dropQualityIndex ||
+ dropPosition !== this.state.dropPosition
+ ) {
+ this.setState({
+ dragQualityIndex,
+ dropQualityIndex: adjustedDropQualityIndex,
+ dropPosition
+ });
+ }
+ }
+
+ onQualityProfileItemDragEnd = (didDrop) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex
+ } = this.state;
+
+ if (didDrop && dropQualityIndex != null) {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ let item = null;
+ let dropGroup = null;
+
+ // Get the group before moving anything so we know the correct place to drop it.
+ if (dropGroupIndex != null) {
+ dropGroup = items[dropGroupIndex];
+ }
+
+ if (dragGroupIndex == null) {
+ item = items.splice(dragItemIndex, 1)[0];
+ } else {
+ const group = items[dragGroupIndex];
+ item = group.items.splice(dragItemIndex, 1)[0];
+
+ // If the group is now empty, destroy it.
+ if (!group.items.length) {
+ items.splice(dragGroupIndex, 1);
+ }
+ }
+
+ if (dropGroupIndex == null) {
+ items.splice(dropItemIndex, 0, item);
+ } else {
+ dropGroup.items.splice(dropItemIndex, 0, item);
+ }
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ }
+
+ onToggleEditGroupsMode = () => {
+ this.setState({ editGroups: !this.state.editGroups });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setQualityProfileValue: PropTypes.func.isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ saveQualityProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
new file mode 100644
index 000000000..341d75aa2
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
@@ -0,0 +1,38 @@
+.qualityProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.qualities {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
+
+.tooltipLabel {
+ composes: label from 'Components/Label.css';
+
+ margin: 0;
+ border: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
new file mode 100644
index 000000000..f4a4ca414
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
@@ -0,0 +1,186 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfile.css';
+
+class QualityProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isEditQualityProfileModalOpen: true });
+ }
+
+ onEditQualityProfileModalClose = () => {
+ this.setState({ isEditQualityProfileModalOpen: false });
+ }
+
+ onDeleteQualityProfilePress = () => {
+ this.setState({
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: true
+ });
+ }
+
+ onDeleteQualityProfileModalClose = () => {
+ this.setState({ isDeleteQualityProfileModalOpen: false });
+ }
+
+ onConfirmDeleteQualityProfile = () => {
+ this.props.onConfirmDeleteQualityProfile(this.props.id);
+ }
+
+ onCloneQualityProfilePress = () => {
+ const {
+ id,
+ onCloneQualityProfilePress
+ } = this.props;
+
+ onCloneQualityProfilePress(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ items,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ items.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ if (item.quality) {
+ const isCutoff = upgradeAllowed && item.quality.id === cutoff;
+
+ return (
+
+ {item.quality.name}
+
+ );
+ }
+
+ const isCutoff = upgradeAllowed && item.id === cutoff;
+
+ return (
+
+ {item.name}
+
+ }
+ tooltip={
+
+ {
+ item.items.map((groupItem) => {
+ return (
+
+ {groupItem.quality.name}
+
+ );
+ })
+ }
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ />
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ upgradeAllowed: PropTypes.bool.isRequired,
+ cutoff: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfile;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
new file mode 100644
index 000000000..7e6370ff8
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
@@ -0,0 +1,85 @@
+.qualityProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+
+ &.isInGroup {
+ border-style: dashed;
+ }
+}
+
+.checkInputContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 5px;
+ margin-left: 8px;
+}
+
+.checkInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 5px;
+}
+
+.qualityNameContainer {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: $qualityProfileItemHeight;
+ cursor: pointer;
+}
+
+.qualityName {
+ &.isInGroup {
+ margin-left: 14px;
+ }
+
+ &.notAllowed {
+ color: #c6c6c6;
+ }
+}
+
+.createGroupButton {
+ composes: buton from 'Components/Link/IconButton.css';
+
+ display: flex;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-right: 5px;
+ margin-left: 8px;
+ width: 20px;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.isPreview {
+ .qualityName {
+ margin-left: 14px;
+
+ &.isInGroup {
+ margin-left: 28px;
+ }
+ }
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
new file mode 100644
index 000000000..8161e7061
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
@@ -0,0 +1,131 @@
+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 IconButton from 'Components/Link/IconButton';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './QualityProfileItem.css';
+
+class QualityProfileItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ qualityId,
+ onQualityProfileItemAllowedChange
+ } = this.props;
+
+ onQualityProfileItemAllowedChange(qualityId, value);
+ }
+
+ onCreateGroupPress = () => {
+ const {
+ qualityId,
+ onCreateGroupPress
+ } = this.props;
+
+ onCreateGroupPress(qualityId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ isPreview,
+ groupId,
+ name,
+ allowed,
+ isDragging,
+ isOverCurrent,
+ connectDragSource
+ } = this.props;
+
+ return (
+
+
+ {
+ editGroups && !groupId && !isPreview &&
+
+ }
+
+ {
+ !editGroups &&
+
+ }
+
+
+ {name}
+
+
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+QualityProfileItem.propTypes = {
+ editGroups: PropTypes.bool,
+ isPreview: PropTypes.bool,
+ groupId: PropTypes.number,
+ qualityId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ isOverCurrent: PropTypes.bool.isRequired,
+ isInGroup: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ onCreateGroupPress: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func
+};
+
+QualityProfileItem.defaultProps = {
+ isPreview: false,
+ isOverCurrent: false,
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default QualityProfileItem;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
new file mode 100644
index 000000000..e0c6e8e8c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import QualityProfileItem from './QualityProfileItem';
+import styles from './QualityProfileItemDragPreview.css';
+
+const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth);
+const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class QualityProfileItemDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ const {
+ editGroups,
+ groupId,
+ qualityId,
+ name,
+ allowed
+ } = item;
+
+ // TODO: Show a different preview for groups
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItemDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
new file mode 100644
index 000000000..d5061cc95
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
@@ -0,0 +1,18 @@
+.qualityProfileItemDragSource {
+ padding: $qualityProfileItemDragSourcePadding 0;
+}
+
+.qualityProfileItemPlaceholder {
+ width: 100%;
+ height: $qualityProfileItemHeight;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.qualityProfileItemPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.qualityProfileItemPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
new file mode 100644
index 000000000..0e1838eb3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
@@ -0,0 +1,241 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import QualityProfileItem from './QualityProfileItem';
+import QualityProfileItemGroup from './QualityProfileItemGroup';
+import styles from './QualityProfileItemDragSource.css';
+
+const qualityProfileItemDragSource = {
+ beginDrag(props) {
+ const {
+ editGroups,
+ qualityIndex,
+ groupId,
+ qualityId,
+ name,
+ allowed
+ } = props;
+
+ return {
+ editGroups,
+ qualityIndex,
+ groupId,
+ qualityId,
+ isGroup: !qualityId,
+ name,
+ allowed
+ };
+ },
+
+ endDrag(props, monitor, component) {
+ props.onQualityProfileItemDragEnd(monitor.didDrop());
+ }
+};
+
+const qualityProfileItemDropTarget = {
+ hover(props, monitor, component) {
+ const {
+ qualityIndex: dragQualityIndex,
+ isGroup: isDragGroup
+ } = monitor.getItem();
+
+ const dropQualityIndex = props.qualityIndex;
+ const isDropGroupItem = !!(props.qualityId && props.groupId);
+
+ // Use childNodeIndex to select the correct node to get the middle of so
+ // we don't bounce between above and below causing rapid setState calls.
+ const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0;
+ const componentDOMNode = findDOMNode(component).children[childNodeIndex];
+ const hoverBoundingRect = componentDOMNode.getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ // If we're hovering over a child don't trigger on the parent
+ if (!monitor.isOver({ shallow: true })) {
+ return;
+ }
+
+ // Don't show targets for dropping on self
+ if (dragQualityIndex === dropQualityIndex) {
+ return;
+ }
+
+ // Don't allow a group to be dropped inside a group
+ if (isDragGroup && isDropGroupItem) {
+ return;
+ }
+
+ let dropPosition = null;
+
+ // Determine drop position based on position over target
+ if (hoverClientY > hoverMiddleY) {
+ dropPosition = 'below';
+ } else if (hoverClientY < hoverMiddleY) {
+ dropPosition = 'above';
+ } else {
+ return;
+ }
+
+ props.onQualityProfileItemDragMove({
+ dragQualityIndex,
+ dropQualityIndex,
+ dropPosition
+ });
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver(),
+ isOverCurrent: monitor.isOver({ shallow: true })
+ };
+}
+
+class QualityProfileItemDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ groupId,
+ qualityId,
+ name,
+ allowed,
+ items,
+ qualityIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOverCurrent,
+ connectDragSource,
+ connectDropTarget,
+ onCreateGroupPress,
+ onDeleteGroupPress,
+ onQualityProfileItemAllowedChange,
+ onItemGroupAllowedChange,
+ onItemGroupNameChange,
+ onQualityProfileItemDragMove,
+ onQualityProfileItemDragEnd
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOverCurrent;
+ const isAfter = !isDragging && isDraggingDown && isOverCurrent;
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+ {
+ !!groupId && qualityId == null &&
+
+ }
+
+ {
+ qualityId != null &&
+
+ }
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemDragSource.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ groupId: PropTypes.number,
+ qualityId: PropTypes.number,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ qualityIndex: PropTypes.string.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOverCurrent: PropTypes.bool,
+ isInGroup: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onCreateGroupPress: PropTypes.func,
+ onDeleteGroupPress: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ onItemGroupAllowedChange: PropTypes.func,
+ onItemGroupNameChange: PropTypes.func,
+ onQualityProfileItemDragMove: PropTypes.func.isRequired,
+ onQualityProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDropTarget,
+ collectDropTarget
+)(DragSource(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDragSource,
+ collectDragSource
+)(QualityProfileItemDragSource));
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css
new file mode 100644
index 000000000..d0720cb6a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css
@@ -0,0 +1,105 @@
+.qualityProfileItemGroup {
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+
+ &.editGroups {
+ background: #fcfcfc;
+ }
+}
+
+.qualityProfileItemGroupInfo {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
+.checkInputContainer {
+ composes: checkInputContainer from './QualityProfileItem.css';
+
+ display: flex;
+ align-items: center;
+}
+
+.checkInput {
+ composes: checkInput from './QualityProfileItem.css';
+}
+
+.nameInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ margin-top: 4px;
+ margin-right: 10px;
+}
+
+.nameContainer {
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+}
+
+.name {
+ flex-shrink: 0;
+
+ &.notAllowed {
+ color: #c6c6c6;
+ }
+}
+
+.groupQualities {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+ flex-wrap: wrap;
+ margin: 2px 0 2px 10px;
+}
+
+.qualityNameContainer {
+ display: flex;
+ align-items: stretch;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+}
+
+.qualityNameLabel {
+ composes: qualityNameContainer;
+
+ cursor: pointer;
+}
+
+.deleteGroupButton {
+ composes: buton from 'Components/Link/IconButton.css';
+
+ display: flex;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-right: 5px;
+ margin-left: 8px;
+ width: 20px;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.items {
+ margin: 0 50px 0 35px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js
new file mode 100644
index 000000000..34008b1ec
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js
@@ -0,0 +1,200 @@
+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 Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import CheckInput from 'Components/Form/CheckInput';
+import TextInput from 'Components/Form/TextInput';
+import QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import styles from './QualityProfileItemGroup.css';
+
+class QualityProfileItemGroup extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ groupId,
+ onItemGroupAllowedChange
+ } = this.props;
+
+ onItemGroupAllowedChange(groupId, value);
+ }
+
+ onNameChange = ({ value }) => {
+ const {
+ groupId,
+ onItemGroupNameChange
+ } = this.props;
+
+ onItemGroupNameChange(groupId, value);
+ }
+
+ onDeleteGroupPress = ({ value }) => {
+ const {
+ groupId,
+ onDeleteGroupPress
+ } = this.props;
+
+ onDeleteGroupPress(groupId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ groupId,
+ name,
+ allowed,
+ items,
+ qualityIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ connectDragSource,
+ onQualityProfileItemAllowedChange,
+ onQualityProfileItemDragMove,
+ onQualityProfileItemDragEnd
+ } = this.props;
+
+ return (
+
+
+ {
+ editGroups &&
+
+
+
+
+
+ }
+
+ {
+ !editGroups &&
+
+
+
+
+
+ {name}
+
+
+
+ {
+ items.map(({ quality }) => {
+ return (
+
+ {quality.name}
+
+ );
+ }).reverse()
+ }
+
+
+
+ }
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+ {
+ editGroups &&
+
+ {
+ items.map(({ quality }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemGroup.propTypes = {
+ editGroups: PropTypes.bool,
+ groupId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualityIndex: PropTypes.string.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ isDraggingUp: PropTypes.bool.isRequired,
+ isDraggingDown: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onItemGroupAllowedChange: PropTypes.func.isRequired,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ onItemGroupNameChange: PropTypes.func.isRequired,
+ onDeleteGroupPress: PropTypes.func.isRequired,
+ onQualityProfileItemDragMove: PropTypes.func.isRequired,
+ onQualityProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+QualityProfileItemGroup.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default QualityProfileItemGroup;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
new file mode 100644
index 000000000..6b4268de9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
@@ -0,0 +1,15 @@
+.editGroupsButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-top: 10px;
+}
+
+.editGroupsButtonIcon {
+ margin-right: 8px;
+}
+
+.qualities {
+ margin-top: 10px;
+ transition: min-height 200ms;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
new file mode 100644
index 000000000..c41d4b77d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
@@ -0,0 +1,181 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import Measure from 'Components/Measure';
+import QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
+import styles from './QualityProfileItems.css';
+
+class QualityProfileItems extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ qualitiesHeight: 0,
+ qualitiesHeightEditGroups: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ height }) => {
+ if (this.props.editGroups) {
+ this.setState({
+ qualitiesHeightEditGroups: height
+ });
+ } else {
+ this.setState({ qualitiesHeight: height });
+ }
+ }
+
+ onToggleEditGroupsMode = () => {
+ this.props.onToggleEditGroupsMode();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ dropQualityIndex,
+ dropPosition,
+ qualityProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ const {
+ qualitiesHeight,
+ qualitiesHeightEditGroups
+ } = this.state;
+
+ const isDragging = dropQualityIndex !== null;
+ const isDraggingUp = isDragging && dropPosition === 'above';
+ const isDraggingDown = isDragging && dropPosition === 'below';
+ const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
+
+ return (
+
+
+ Qualities
+
+
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ editGroups ? 'Done Editing Groups' : 'Edit Groups'
+ }
+
+
+
+
+
+ {
+ qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => {
+ const identifier = quality ? quality.id : id;
+
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItems.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ dragQualityIndex: PropTypes.string,
+ dropQualityIndex: PropTypes.string,
+ dropPosition: PropTypes.string,
+ qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ onToggleEditGroupsMode: PropTypes.func.isRequired
+};
+
+QualityProfileItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default QualityProfileItems;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
new file mode 100644
index 000000000..bf13815ff
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ createQualityProfileSelector(),
+ (qualityProfile) => {
+ return {
+ name: qualityProfile.name
+ };
+ }
+ );
+}
+
+function QualityProfileNameConnector({ name, ...otherProps }) {
+ return (
+
+ {name}
+
+ );
+}
+
+QualityProfileNameConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(QualityProfileNameConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
new file mode 100644
index 000000000..9644a7c2d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
@@ -0,0 +1,21 @@
+.qualityProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addQualityProfile {
+ composes: qualityProfile from './QualityProfile.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
new file mode 100644
index 000000000..cf1a21422
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
@@ -0,0 +1,107 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import QualityProfile from './QualityProfile';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfiles.css';
+
+class QualityProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCloneQualityProfilePress = (id) => {
+ this.props.onCloneQualityProfilePress(id);
+ this.setState({ isQualityProfileModalOpen: true });
+ }
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isQualityProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isQualityProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteQualityProfile,
+ onCloneQualityProfilePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfiles;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
new file mode 100644
index 000000000..c7596ad63
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions';
+import QualityProfiles from './QualityProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ return {
+ ...qualityProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityProfiles: fetchQualityProfiles,
+ dispatchDeleteQualityProfile: deleteQualityProfile,
+ dispatchCloneQualityProfile: cloneQualityProfile
+};
+
+class QualityProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchQualityProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteQualityProfile = (id) => {
+ this.props.dispatchDeleteQualityProfile({ id });
+ }
+
+ onCloneQualityProfilePress = (id) => {
+ this.props.dispatchCloneQualityProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfilesConnector.propTypes = {
+ dispatchFetchQualityProfiles: PropTypes.func.isRequired,
+ dispatchDeleteQualityProfile: PropTypes.func.isRequired,
+ dispatchCloneQualityProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js
new file mode 100644
index 000000000..5d0e74287
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector';
+
+function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditReleaseProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditReleaseProfileModal;
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js
new file mode 100644
index 000000000..89b605652
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditReleaseProfileModal from './EditReleaseProfileModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditReleaseProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.releaseProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditReleaseProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js
new file mode 100644
index 000000000..81a93dd5a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js
@@ -0,0 +1,158 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+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 Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditReleaseProfileModalContent.css';
+
+function EditReleaseProfileModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onModalClose,
+ onSavePress,
+ onDeleteReleaseProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ required,
+ ignored,
+ preferred,
+ includePreferredWhenRenaming,
+ tags
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Release Profile' : 'Add Release Profile'}
+
+
+
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditReleaseProfileModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteReleaseProfilePress: PropTypes.func
+};
+
+export default EditReleaseProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js
new file mode 100644
index 000000000..a5fbaa680
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js
@@ -0,0 +1,113 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setReleaseProfileValue, saveReleaseProfile } from 'Store/Actions/settingsActions';
+import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
+
+const newReleaseProfile = {
+ required: '',
+ ignored: '',
+ preferred: '',
+ includePreferredWhenRenaming: false,
+ tags: []
+};
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.releaseProfiles,
+ (id, releaseProfiles) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = releaseProfiles;
+
+ const profile = id ? _.find(items, { id }) : newReleaseProfile;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setReleaseProfileValue,
+ saveReleaseProfile
+};
+
+class EditReleaseProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newReleaseProfile).forEach((name) => {
+ this.props.setReleaseProfileValue({
+ name,
+ value: newReleaseProfile[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setReleaseProfileValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveReleaseProfile({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditReleaseProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setReleaseProfileValue: PropTypes.func.isRequired,
+ saveReleaseProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.css b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css
new file mode 100644
index 000000000..8c703d283
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css
@@ -0,0 +1,11 @@
+.releaseProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.js b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js
new file mode 100644
index 000000000..0455594c2
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js
@@ -0,0 +1,168 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import split from 'Utilities/String/split';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import TagList from 'Components/TagList';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
+import styles from './ReleaseProfile.css';
+
+class ReleaseProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditReleaseProfileModalOpen: false,
+ isDeleteReleaseProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditReleaseProfilePress = () => {
+ this.setState({ isEditReleaseProfileModalOpen: true });
+ }
+
+ onEditReleaseProfileModalClose = () => {
+ this.setState({ isEditReleaseProfileModalOpen: false });
+ }
+
+ onDeleteReleaseProfilePress = () => {
+ this.setState({
+ isEditReleaseProfileModalOpen: false,
+ isDeleteReleaseProfileModalOpen: true
+ });
+ }
+
+ onDeleteReleaseProfileModalClose= () => {
+ this.setState({ isDeleteReleaseProfileModalOpen: false });
+ }
+
+ onConfirmDeleteReleaseProfile = () => {
+ this.props.onConfirmDeleteReleaseProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ required,
+ ignored,
+ preferred,
+ tags,
+ tagList
+ } = this.props;
+
+ return (
+
+
+ {
+ split(required).map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+ {item}
+
+ );
+ })
+ }
+
+
+
+ {
+ split(ignored).map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+ {item}
+
+ );
+ })
+ }
+
+
+
+ {
+ preferred.map((item) => {
+ const isPreferred = item.value >= 0;
+
+ return (
+
+ {item.key} {isPreferred && '+'}{item.value}
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ReleaseProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ required: PropTypes.string.isRequired,
+ ignored: PropTypes.string.isRequired,
+ preferred: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
+};
+
+ReleaseProfile.defaultProps = {
+ required: '',
+ ignored: '',
+ preferred: []
+};
+
+export default ReleaseProfile;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css
new file mode 100644
index 000000000..e3573452e
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css
@@ -0,0 +1,20 @@
+.releaseProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addReleaseProfile {
+ composes: releaseProfile from './ReleaseProfile.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js
new file mode 100644
index 000000000..73c648a04
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import ReleaseProfile from './ReleaseProfile';
+import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
+import styles from './ReleaseProfiles.css';
+
+class ReleaseProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddReleaseProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddReleaseProfilePress = () => {
+ this.setState({ isAddReleaseProfileModalOpen: true });
+ }
+
+ onAddReleaseProfileModalClose = () => {
+ this.setState({ isAddReleaseProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ tagList,
+ onConfirmDeleteReleaseProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ReleaseProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
+};
+
+export default ReleaseProfiles;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js
new file mode 100644
index 000000000..dd4b41171
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchReleaseProfiles, deleteReleaseProfile } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import ReleaseProfiles from './ReleaseProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.releaseProfiles,
+ createTagsSelector(),
+ (releaseProfiles, tagList) => {
+ return {
+ ...releaseProfiles,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchReleaseProfiles,
+ deleteReleaseProfile
+};
+
+class ReleaseProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchReleaseProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteReleaseProfile = (id) => {
+ this.props.deleteReleaseProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ReleaseProfilesConnector.propTypes = {
+ fetchReleaseProfiles: PropTypes.func.isRequired,
+ deleteReleaseProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
new file mode 100644
index 000000000..ccfd00c7a
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
@@ -0,0 +1,93 @@
+.qualityDefinition {
+ display: flex;
+ align-content: stretch;
+ margin: 5px 0;
+ padding-top: 5px;
+ height: 45px;
+ border-top: 1px solid $borderColor;
+}
+
+.quality,
+.title {
+ flex: 0 1 250px;
+ padding-right: 20px;
+ line-height: 40px;
+}
+
+.sizeLimit {
+ flex: 0 1 500px;
+ padding-right: 30px;
+}
+
+.slider {
+ width: 100%;
+ height: 20px;
+}
+
+.bar {
+ top: 9px;
+ margin: 0 5px;
+ height: 3px;
+ background-color: $sliderAccentColor;
+ box-shadow: 0 0 0 #000;
+
+ &:nth-child(odd) {
+ background-color: #ddd;
+ }
+}
+
+.handle {
+ top: 1px;
+ z-index: 0 !important;
+ width: 18px;
+ height: 18px;
+ border: 3px solid $sliderAccentColor;
+ border-radius: 50%;
+ background-color: $white;
+ text-align: center;
+ cursor: pointer;
+}
+
+.sizes {
+ display: flex;
+ justify-content: space-between;
+}
+
+.megabytesPerMinute {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 250px;
+}
+
+.sizeInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ display: inline-block;
+ margin-left: 5px;
+ padding: 6px;
+ width: 75px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .qualityDefinition {
+ flex-wrap: wrap;
+ height: auto;
+
+ &:first-child {
+ border-top: none;
+ }
+ }
+
+ .qualityDefinition:first-child {
+ border-top: none;
+ }
+
+ .quality {
+ font-weight: bold;
+ line-height: inherit;
+ }
+
+ .sizeLimit {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
new file mode 100644
index 000000000..2ff558037
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
@@ -0,0 +1,193 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactSlider from 'react-slider';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import NumberInput from 'Components/Form/NumberInput';
+import TextInput from 'Components/Form/TextInput';
+import styles from './QualityDefinition.css';
+
+const slider = {
+ min: 0,
+ max: 200,
+ step: 0.1
+};
+
+function getValue(value) {
+ if (value < slider.min) {
+ return slider.min;
+ }
+
+ if (value > slider.max) {
+ return slider.max;
+ }
+
+ return value;
+}
+
+class QualityDefinition extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._forceUpdateTimeout = null;
+ }
+
+ componentDidMount() {
+ // A hack to deal with a bug in the slider component until a fix for it
+ // lands and an updated version is available.
+ // See: https://github.com/mpowaga/react-slider/issues/115
+
+ this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1);
+ }
+
+ componentWillUnmount() {
+ if (this._forceUpdateTimeout) {
+ clearTimeout(this._forceUpdateTimeout);
+ }
+ }
+
+ //
+ // Listeners
+
+ onSizeChange = ([minSize, maxSize]) => {
+ maxSize = maxSize === slider.max ? null : maxSize;
+
+ this.props.onSizeChange({ minSize, maxSize });
+ }
+
+ onMinSizeChange = ({ value }) => {
+ const minSize = getValue(value);
+
+ this.props.onSizeChange({
+ minSize,
+ maxSize: this.props.maxSize
+ });
+ }
+
+ onMaxSizeChange = ({ value }) => {
+ const maxSize = value === slider.max ? null : getValue(value);
+
+ this.props.onSizeChange({
+ minSize: this.props.minSize,
+ maxSize
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ quality,
+ title,
+ minSize,
+ maxSize,
+ advancedSettings,
+ onTitleChange
+ } = this.props;
+
+ const minBytes = minSize * 1024 * 1024;
+ const minThirty = formatBytes(minBytes * 30, 2);
+ const minSixty = formatBytes(minBytes * 60, 2);
+
+ const maxBytes = maxSize && maxSize * 1024 * 1024;
+ const maxThirty = maxBytes ? formatBytes(maxBytes * 30, 2) : 'Unlimited';
+ const maxSixty = maxBytes ? formatBytes(maxBytes * 60, 2) : 'Unlimited';
+
+ return (
+
+
+ {quality.name}
+
+
+
+
+
+
+
+
+
+
+
+ {minThirty}
+ {minSixty}
+
+
+
+ {maxThirty}
+ {maxSixty}
+
+
+
+
+ {
+ advancedSettings &&
+
+
+ Min
+
+
+
+
+
+ Max
+
+
+
+
+ }
+
+ );
+ }
+}
+
+QualityDefinition.propTypes = {
+ id: PropTypes.number.isRequired,
+ quality: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ minSize: PropTypes.number,
+ maxSize: PropTypes.number,
+ advancedSettings: PropTypes.bool.isRequired,
+ onTitleChange: PropTypes.func.isRequired,
+ onSizeChange: PropTypes.func.isRequired
+};
+
+export default QualityDefinition;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js
new file mode 100644
index 000000000..9404dfd9f
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { setQualityDefinitionValue } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import QualityDefinition from './QualityDefinition';
+
+function mapStateToProps(state) {
+ return {
+ advancedSettings: state.settings.advancedSettings
+ };
+}
+
+const mapDispatchToProps = {
+ setQualityDefinitionValue,
+ clearPendingChanges
+};
+
+class QualityDefinitionConnector extends Component {
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' });
+ }
+
+ //
+ // Listeners
+
+ onTitleChange = ({ value }) => {
+ this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value });
+ }
+
+ onSizeChange = ({ minSize, maxSize }) => {
+ const {
+ id,
+ minSize: currentMinSize,
+ maxSize: currentMaxSize
+ } = this.props;
+
+ if (minSize !== currentMinSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize });
+ }
+
+ if (minSize !== currentMaxSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ minSize: PropTypes.number,
+ maxSize: PropTypes.number,
+ setQualityDefinitionValue: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(QualityDefinitionConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css
new file mode 100644
index 000000000..689017684
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css
@@ -0,0 +1,41 @@
+.header {
+ display: flex;
+ font-weight: bold;
+}
+
+.quality,
+.title {
+ flex: 0 1 250px;
+}
+
+.sizeLimit {
+ flex: 0 1 500px;
+}
+
+.megabytesPerMinute {
+ flex: 0 0 250px;
+}
+
+.sizeLimitHelpTextContainer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+ max-width: 1000px;
+}
+
+.sizeLimitHelpText {
+ max-width: 500px;
+ color: $helpTextColor;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .header {
+ display: none;
+ }
+
+ .definitions {
+ &:first-child {
+ border-top: none;
+ }
+ }
+}
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
new file mode 100644
index 000000000..18db844f8
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import QualityDefinitionConnector from './QualityDefinitionConnector';
+import styles from './QualityDefinitions.css';
+
+class QualityDefinitions extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
Quality
+
Title
+
Size Limit
+
Megabytes Per Minute
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ Limits are automatically adjusted for the series runtime and number of episodes in the file.
+
+
+
+
+ );
+ }
+}
+
+QualityDefinitions.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default QualityDefinitions;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
new file mode 100644
index 000000000..9a3e0a90c
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
@@ -0,0 +1,90 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions';
+import QualityDefinitions from './QualityDefinitions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityDefinitions,
+ (qualityDefinitions) => {
+ const items = qualityDefinitions.items.map((item) => {
+ const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {};
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ return {
+ ...qualityDefinitions,
+ items,
+ hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges)
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityDefinitions: fetchQualityDefinitions,
+ dispatchSaveQualityDefinitions: saveQualityDefinitions
+};
+
+class QualityDefinitionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchQualityDefinitions();
+
+ const {
+ dispatchFetchQualityDefinitions,
+ dispatchSaveQualityDefinitions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchQualityDefinitions();
+ onChildMounted(dispatchSaveQualityDefinitions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchQualityDefinitions: PropTypes.func.isRequired,
+ dispatchSaveQualityDefinitions: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps, null, { withRef: true })(QualityDefinitionsConnector);
diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js
new file mode 100644
index 000000000..dfd6a24d7
--- /dev/null
+++ b/frontend/src/Settings/Quality/Quality.js
@@ -0,0 +1,68 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector';
+
+class Quality extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Quality;
diff --git a/frontend/src/Settings/Settings.css b/frontend/src/Settings/Settings.css
new file mode 100644
index 000000000..497ef47c0
--- /dev/null
+++ b/frontend/src/Settings/Settings.css
@@ -0,0 +1,18 @@
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ border-bottom: 1px solid #e5e5e5;
+ color: #3a3f51;
+ font-size: 21px;
+
+ &:hover {
+ color: #616573;
+ text-decoration: none;
+ }
+}
+
+.summary {
+ margin-top: 10px;
+ margin-bottom: 30px;
+ color: $dimColor;
+}
diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js
new file mode 100644
index 000000000..5222ec415
--- /dev/null
+++ b/frontend/src/Settings/Settings.js
@@ -0,0 +1,133 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from './SettingsToolbarConnector';
+import styles from './Settings.css';
+
+function Settings() {
+ return (
+
+
+
+
+
+ Media Management
+
+
+
+ Naming and file management settings
+
+
+
+ Profiles
+
+
+
+ Quality, Language, Delay and Release profiles
+
+
+
+ Quality
+
+
+
+ Quality sizes and naming
+
+
+
+ Indexers
+
+
+
+ Indexers and indexer options
+
+
+
+ Download Clients
+
+
+
+ Download clients, download handling and remote path mappings
+
+
+
+ Connect
+
+
+
+ Notifications, connections to media servers/players and custom scripts
+
+
+
+ Metadata
+
+
+
+ Create metadata files when episodes are imported or series are refreshed
+
+
+
+ Tags
+
+
+
+ See all tags and how they are used. Unused tags can be removed
+
+
+
+ General
+
+
+
+ Port, SSL, username/password, proxy, analytics and updates
+
+
+
+ UI
+
+
+
+ Calendar, date and color impaired options
+
+
+
+ );
+}
+
+Settings.propTypes = {
+};
+
+export default Settings;
diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js
new file mode 100644
index 000000000..8b70857d9
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbar.js
@@ -0,0 +1,105 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PendingChangesModal from './PendingChangesModal';
+import AdvancedSettingsButton from './AdvancedSettingsButton';
+
+class SettingsToolbar extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true });
+ }
+
+ //
+ // Control
+
+ saveSettings = (event) => {
+ event.preventDefault();
+
+ const {
+ hasPendingChanges,
+ onSavePress
+ } = this.props;
+
+ if (hasPendingChanges) {
+ onSavePress();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ showSave,
+ isSaving,
+ hasPendingChanges,
+ hasPendingLocation,
+ additionalButtons,
+ onSavePress,
+ onConfirmNavigation,
+ onCancelNavigation,
+ onAdvancedSettingsPress
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ showSave &&
+
+ }
+
+ {
+ additionalButtons
+ }
+
+
+
+
+ );
+ }
+}
+
+SettingsToolbar.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ showSave: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool,
+ hasPendingLocation: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool,
+ additionalButtons: PropTypes.node,
+ onSavePress: PropTypes.func,
+ onAdvancedSettingsPress: PropTypes.func.isRequired,
+ onConfirmNavigation: PropTypes.func.isRequired,
+ onCancelNavigation: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+SettingsToolbar.defaultProps = {
+ showSave: true
+};
+
+export default keyboardShortcuts(SettingsToolbar);
diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js
new file mode 100644
index 000000000..8bfb3dad5
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbarConnector.js
@@ -0,0 +1,147 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
+import SettingsToolbar from './SettingsToolbar';
+
+function mapStateToProps(state) {
+ return {
+ advancedSettings: state.settings.advancedSettings
+ };
+}
+
+const mapDispatchToProps = {
+ toggleAdvancedSettings
+};
+
+class SettingsToolbarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ };
+
+ this._unblock = null;
+ }
+
+ componentDidMount() {
+ this._unblock = this.props.history.block(this.routerWillLeave);
+ }
+
+ componentWillUnmount() {
+ if (this._unblock) {
+ this._unblock();
+ }
+ }
+
+ //
+ // Control
+
+ routerWillLeave = (nextLocation, nextLocationAction) => {
+ if (this.state.confirmed) {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+
+ return true;
+ }
+
+ if (this.props.hasPendingChanges ) {
+ this.setState({
+ nextLocation,
+ nextLocationAction
+ });
+
+ return false;
+ }
+
+ return true;
+ }
+
+ //
+ // Listeners
+
+ onAdvancedSettingsPress = () => {
+ this.props.toggleAdvancedSettings();
+ }
+
+ onConfirmNavigation = () => {
+ const {
+ nextLocation,
+ nextLocationAction
+ } = this.state;
+
+ const history = this.props.history;
+
+ const path = `${nextLocation.pathname}${nextLocation.search}`;
+
+ this.setState({
+ confirmed: true
+ }, () => {
+ if (nextLocationAction === 'PUSH') {
+ history.push(path);
+ } else {
+ // Unfortunately back and forward both use POP,
+ // which means we don't actually know which direction
+ // the user wanted to go, assuming back.
+
+ history.goBack();
+ }
+ });
+ }
+
+ onCancelNavigation = () => {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const hasPendingLocation = this.state.nextLocation !== null;
+
+ return (
+
+ );
+ }
+}
+
+const historyShape = {
+ block: PropTypes.func.isRequired,
+ goBack: PropTypes.func.isRequired,
+ push: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.propTypes = {
+ hasPendingChanges: PropTypes.bool.isRequired,
+ history: PropTypes.shape(historyShape).isRequired,
+ onSavePress: PropTypes.func,
+ toggleAdvancedSettings: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.defaultProps = {
+ hasPendingChanges: false
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector));
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js
new file mode 100644
index 000000000..ab670359b
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import titleCase from 'Utilities/String/titleCase';
+
+function TagDetailsDelayProfile(props) {
+ const {
+ preferredProtocol,
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay
+ } = props;
+
+ return (
+
+
+ Protocol: {titleCase(preferredProtocol)}
+
+
+
+ {
+ enableUsenet ?
+ `Usenet Delay: ${usenetDelay}` :
+ 'Usenet disabled'
+ }
+
+
+
+ {
+ enableTorrent ?
+ `Torrent Delay: ${torrentDelay}` :
+ 'Torrents disabled'
+ }
+
+
+ );
+}
+
+TagDetailsDelayProfile.propTypes = {
+ preferredProtocol: PropTypes.string.isRequired,
+ enableUsenet: PropTypes.bool.isRequired,
+ enableTorrent: PropTypes.bool.isRequired,
+ usenetDelay: PropTypes.number.isRequired,
+ torrentDelay: PropTypes.number.isRequired
+};
+
+export default TagDetailsDelayProfile;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModal.js b/frontend/src/Settings/Tags/Details/TagDetailsModal.js
new file mode 100644
index 000000000..0fe1ec5d3
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import TagDetailsModalContentConnector from './TagDetailsModalContentConnector';
+
+function TagDetailsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+TagDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TagDetailsModal;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css
new file mode 100644
index 000000000..3488b0509
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css
@@ -0,0 +1,26 @@
+.items {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.item {
+ flex: 0 0 100%;
+}
+
+.restriction {
+ margin-bottom: 5px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-child {
+ margin: 0;
+ padding: 0;
+ border-bottom: none;
+ }
+}
+
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
new file mode 100644
index 000000000..eadc9f468
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import split from 'Utilities/String/split';
+import { kinds } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Button from 'Components/Link/Button';
+import Label from 'Components/Label';
+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 TagDetailsDelayProfile from './TagDetailsDelayProfile';
+import styles from './TagDetailsModalContent.css';
+
+function TagDetailsModalContent(props) {
+ const {
+ label,
+ isTagUsed,
+ series,
+ delayProfiles,
+ notifications,
+ releaseProfiles,
+ onModalClose,
+ onDeleteTagPress
+ } = props;
+
+ return (
+
+
+ Tag Details - {label}
+
+
+
+ {
+ !isTagUsed &&
+ Tag is not used and can be deleted
+ }
+
+ {
+ !!series.length &&
+
+ {
+ series.map((item) => {
+ return (
+
+ {item.title}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!delayProfiles.length &&
+
+ {
+ delayProfiles.map((item) => {
+ const {
+ id,
+ preferredProtocol,
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay
+ } = item;
+
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!notifications.length &&
+
+ {
+ notifications.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!releaseProfiles.length &&
+
+ {
+ releaseProfiles.map((item) => {
+ return (
+
+
+ {
+ split(item.required).map((r) => {
+ return (
+
+ {r}
+
+ );
+ })
+ }
+
+
+
+ {
+ split(item.ignored).map((i) => {
+ return (
+
+ {i}
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+
+ }
+
+
+
+ {
+
+ Delete
+
+ }
+
+
+ Close
+
+
+
+ );
+}
+
+TagDetailsModalContent.propTypes = {
+ label: PropTypes.string.isRequired,
+ isTagUsed: PropTypes.bool.isRequired,
+ series: PropTypes.arrayOf(PropTypes.object).isRequired,
+ delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
+ releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteTagPress: PropTypes.func.isRequired
+};
+
+export default TagDetailsModalContent;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
new file mode 100644
index 000000000..5a6c9e229
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
@@ -0,0 +1,61 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import TagDetailsModalContent from './TagDetailsModalContent';
+
+function findMatchingItems(ids, items) {
+ return items.filter((s) => {
+ return ids.includes(s.id);
+ });
+}
+
+function createMatchingSeriesSelector() {
+ return createSelector(
+ (state, { seriesIds }) => seriesIds,
+ createAllSeriesSelector(),
+ findMatchingItems
+ );
+}
+
+function createMatchingDelayProfilesSelector() {
+ return createSelector(
+ (state, { delayProfileIds }) => delayProfileIds,
+ (state) => state.settings.delayProfiles.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingNotificationsSelector() {
+ return createSelector(
+ (state, { notificationIds }) => notificationIds,
+ (state) => state.settings.notifications.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingReleaseProfilesSelector() {
+ return createSelector(
+ (state, { restrictionIds }) => restrictionIds,
+ (state) => state.settings.releaseProfiles.items,
+ findMatchingItems
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createMatchingSeriesSelector(),
+ createMatchingDelayProfilesSelector(),
+ createMatchingNotificationsSelector(),
+ createMatchingReleaseProfilesSelector(),
+ (series, delayProfiles, notifications, releaseProfiles) => {
+ return {
+ series,
+ delayProfiles,
+ notifications,
+ releaseProfiles
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(TagDetailsModalContent);
diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css
new file mode 100644
index 000000000..ee425e309
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.css
@@ -0,0 +1,11 @@
+.tag {
+ composes: card from 'Components/Card.css';
+
+ width: 150px;
+}
+
+.label {
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js
new file mode 100644
index 000000000..0cb9ee208
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TagDetailsModal from './Details/TagDetailsModal';
+import styles from './Tag.css';
+
+class Tag extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false,
+ isDeleteTagModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onShowDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ onDeleteTagPress = () => {
+ this.setState({
+ isDetailsModalOpen: false,
+ isDeleteTagModalOpen: true
+ });
+ }
+
+ onDeleteTagModalClose= () => {
+ this.setState({ isDeleteTagModalOpen: false });
+ }
+
+ onConfirmDeleteTag = () => {
+ this.props.onConfirmDeleteTag({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ label,
+ delayProfileIds,
+ notificationIds,
+ restrictionIds,
+ seriesIds
+ } = this.props;
+
+ const {
+ isDetailsModalOpen,
+ isDeleteTagModalOpen
+ } = this.state;
+
+ const isTagUsed = !!(
+ delayProfileIds.length ||
+ notificationIds.length ||
+ restrictionIds.length ||
+ seriesIds.length
+ );
+
+ return (
+
+
+ {label}
+
+
+ {
+ isTagUsed &&
+
+ {
+ !!seriesIds.length &&
+
+ {seriesIds.length} series
+
+ }
+
+ {
+ !!delayProfileIds.length &&
+
+ {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
+
+ }
+
+ {
+ !!notificationIds.length &&
+
+ {notificationIds.length} connection{notificationIds.length > 1 && 's'}
+
+ }
+
+ {
+ !!restrictionIds.length &&
+
+ {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'}
+
+ }
+
+ }
+
+ {
+ !isTagUsed &&
+
+ No links
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Tag.propTypes = {
+ id: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onConfirmDeleteTag: PropTypes.func.isRequired
+};
+
+Tag.defaultProps = {
+ delayProfileIds: [],
+ notificationIds: [],
+ restrictionIds: [],
+ seriesIds: []
+};
+
+export default Tag;
diff --git a/frontend/src/Settings/Tags/TagConnector.js b/frontend/src/Settings/Tags/TagConnector.js
new file mode 100644
index 000000000..50f610153
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagConnector.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector';
+import { deleteTag } from 'Store/Actions/tagActions';
+import Tag from './Tag';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagDetailsSelector(),
+ (tagDetails) => {
+ return {
+ ...tagDetails
+ };
+ }
+ );
+}
+
+const mapStateToProps = {
+ onConfirmDeleteTag: deleteTag
+};
+
+export default connect(createMapStateToProps, mapStateToProps)(Tag);
diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js
new file mode 100644
index 000000000..56ef92b49
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagSettings.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import TagsConnector from './TagsConnector';
+
+function TagSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default TagSettings;
diff --git a/frontend/src/Settings/Tags/Tags.css b/frontend/src/Settings/Tags/Tags.css
new file mode 100644
index 000000000..5a44f8331
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.css
@@ -0,0 +1,4 @@
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Tags/Tags.js b/frontend/src/Settings/Tags/Tags.js
new file mode 100644
index 000000000..e1375ba76
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import TagConnector from './TagConnector';
+import styles from './Tags.css';
+
+function Tags(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ if (!items.length) {
+ return (
+ No tags have been added yet
+ );
+ }
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+}
+
+Tags.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Tags;
diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js
new file mode 100644
index 000000000..3439f6d0d
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagsConnector.js
@@ -0,0 +1,72 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchTagDetails } from 'Store/Actions/tagActions';
+import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
+import Tags from './Tags';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.tags,
+ (tags) => {
+ const isFetching = tags.isFetching || tags.details.isFetching;
+ const error = tags.error || tags.details.error;
+ const isPopulated = tags.isPopulated && tags.details.isPopulated;
+
+ return {
+ ...tags,
+ isFetching,
+ error,
+ isPopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchTagDetails: fetchTagDetails,
+ dispatchFetchDelayProfiles: fetchDelayProfiles,
+ dispatchFetchNotifications: fetchNotifications,
+ dispatchFetchReleaseProfiles: fetchReleaseProfiles
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchTagDetails,
+ dispatchFetchDelayProfiles,
+ dispatchFetchNotifications,
+ dispatchFetchReleaseProfiles
+ } = this.props;
+
+ dispatchFetchTagDetails();
+ dispatchFetchDelayProfiles();
+ dispatchFetchNotifications();
+ dispatchFetchReleaseProfiles();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ dispatchFetchTagDetails: PropTypes.func.isRequired,
+ dispatchFetchDelayProfiles: PropTypes.func.isRequired,
+ dispatchFetchNotifications: PropTypes.func.isRequired,
+ dispatchFetchReleaseProfiles: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js
new file mode 100644
index 000000000..71356a5f0
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.js
@@ -0,0 +1,195 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+export const firstDayOfWeekOptions = [
+ { key: 0, value: 'Sunday' },
+ { key: 1, value: 'Monday' }
+];
+
+export const weekColumnOptions = [
+ { key: 'ddd M/D', value: 'Tue 3/25' },
+ { key: 'ddd MM/DD', value: 'Tue 03/25' },
+ { key: 'ddd D/M', value: 'Tue 25/03' },
+ { key: 'ddd DD/MM', value: 'Tue 25/03' }
+];
+
+const shortDateFormatOptions = [
+ { key: 'MMM D YYYY', value: 'Mar 25 2014' },
+ { key: 'DD MMM YYYY', value: '25 Mar 2014' },
+ { key: 'MM/D/YYYY', value: '03/25/2014' },
+ { key: 'MM/DD/YYYY', value: '03/25/2014' },
+ { key: 'DD/MM/YYYY', value: '25/03/2014' },
+ { key: 'YYYY-MM-DD', value: '2014-03-25' }
+];
+
+const longDateFormatOptions = [
+ { key: 'dddd, MMMM D YYYY', value: 'Tuesday, March 25, 2014' },
+ { key: 'dddd, D MMMM YYYY', value: 'Tuesday, 25 March, 2014' }
+];
+
+export const timeFormatOptions = [
+ { key: 'h(:mm)a', value: '5pm/5:30pm' },
+ { key: 'HH:mm', value: '17:00/17:30' }
+];
+
+class UISettings extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load UI settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+ }
+
+}
+
+UISettings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default UISettings;
diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js
new file mode 100644
index 000000000..24b55b6f0
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettingsConnector.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { setUISettingsValue, saveUISettings, fetchUISettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import UISettings from './UISettings';
+
+const SECTION = 'ui';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setUISettingsValue,
+ saveUISettings,
+ fetchUISettings,
+ clearPendingChanges
+};
+
+class UISettingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUISettings();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setUISettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveUISettings();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UISettingsConnector.propTypes = {
+ setUISettingsValue: PropTypes.func.isRequired,
+ saveUISettings: PropTypes.func.isRequired,
+ fetchUISettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector);
diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js
new file mode 100644
index 000000000..14c98c8ba
--- /dev/null
+++ b/frontend/src/Shared/piwikCheck.js
@@ -0,0 +1,11 @@
+if (window.Sonarr.analytics) {
+ const d = document;
+ const g = d.createElement('script');
+ const s = d.getElementsByTagName('script')[0];
+
+ g.type = 'text/javascript';
+ g.async = true;
+ g.defer = true;
+ g.src = '//piwik.sonarr.tv/piwik.js';
+ s.parentNode.insertBefore(g, s);
+}
diff --git a/frontend/src/Shims/jquery.js b/frontend/src/Shims/jquery.js
new file mode 100644
index 000000000..d0234889c
--- /dev/null
+++ b/frontend/src/Shims/jquery.js
@@ -0,0 +1,10 @@
+import $ from 'jquery';
+import ajax from 'jQuery/jquery.ajax';
+
+ajax($);
+
+const jquery = $;
+window.$ = $;
+window.jQuery = $;
+
+export default jquery;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js
new file mode 100644
index 000000000..2952973a9
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js
@@ -0,0 +1,12 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createClearReducer(section, defaultState) {
+ return (state) => {
+ const newState = Object.assign(getSectionState(state, section), defaultState);
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createClearReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js
new file mode 100644
index 000000000..d58bb1cd4
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js
@@ -0,0 +1,14 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetClientSideCollectionFilterReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ newState.selectedFilterKey = payload.selectedFilterKey;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionFilterReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js
new file mode 100644
index 000000000..1bc048a80
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js
@@ -0,0 +1,29 @@
+import { sortDirections } from 'Helpers/Props';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetClientSideCollectionSortReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ const sortKey = payload.sortKey || newState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === newState.sortKey) {
+ sortDirection = newState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = newState.sortDirection;
+ }
+ }
+
+ newState.sortKey = sortKey;
+ newState.sortDirection = sortDirection;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionSortReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js
new file mode 100644
index 000000000..3af58dd3b
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js
@@ -0,0 +1,23 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetProviderFieldValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+ const fields = Object.assign({}, newState.pendingChanges.fields || {});
+
+ fields[name] = value;
+
+ newState.pendingChanges.fields = fields;
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetProviderFieldValueReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js
new file mode 100644
index 000000000..474eb7bb2
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetSettingValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+
+ const currentValue = newState.item ? newState.item[name] : null;
+ const pendingState = newState.pendingChanges;
+
+ let parsedValue = null;
+
+ if (_.isNumber(currentValue) && value != null) {
+ parsedValue = parseInt(value);
+ } else {
+ parsedValue = value;
+ }
+
+ if (currentValue === parsedValue) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = parsedValue;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetSettingValueReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js
new file mode 100644
index 000000000..70b57446d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const whitelistedProperties = [
+ 'pageSize',
+ 'columns',
+ 'tableOptions'
+];
+
+function createSetTableOptionReducer(section) {
+ return (state, { payload }) => {
+ const newState = Object.assign(
+ getSectionState(state, section),
+ _.pick(payload, whitelistedProperties));
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetTableOptionReducer;
diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
new file mode 100644
index 000000000..87be89828
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
@@ -0,0 +1,42 @@
+import $ from 'jquery';
+import updateEpisodes from 'Utilities/Episode/updateEpisodes';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const {
+ episodeIds,
+ monitored
+ } = payload;
+
+ const state = getSectionState(getState(), section, true);
+
+ dispatch(updateEpisodes(section, state.items, episodeIds, {
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/episode/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ episodeIds, monitored }),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ dispatch(updateEpisodes(section, state.items, episodeIds, {
+ isSaving: false,
+ monitored
+ }));
+
+ dispatch(fetchHandler());
+ });
+
+ promise.fail(() => {
+ dispatch(updateEpisodes(section, state.items, episodeIds, {
+ isSaving: false
+ }));
+ });
+ };
+}
+
+export default createBatchToggleEpisodeMonitoredHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js
new file mode 100644
index 000000000..c9cd058bd
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js
@@ -0,0 +1,44 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, update, updateItem } from '../baseActions';
+
+export default function createFetchHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: id == null ? url : `${url}/${id}`,
+ data: otherPayload,
+ traditional: true
+ });
+
+ request.done((data) => {
+ dispatch(batchActions([
+ id == null ? update({ section, data }) : updateItem({ section, ...data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr.aborted ? null : xhr
+ }));
+ });
+
+ return abortRequest;
+ };
+}
diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
new file mode 100644
index 000000000..2c02c8c20
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
@@ -0,0 +1,33 @@
+import $ from 'jquery';
+import { set } from '../baseActions';
+
+function createFetchSchemaHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSchemaFetching: true }));
+
+ const promise = $.ajax({
+ url
+ });
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isSchemaFetching: false,
+ isSchemaPopulated: true,
+ schemaError: null,
+ schema: data
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSchemaFetching: false,
+ isSchemaPopulated: true,
+ schemaError: xhr
+ }));
+ });
+ };
+}
+
+export default createFetchSchemaHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
new file mode 100644
index 000000000..e7f1d4b04
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
@@ -0,0 +1,67 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
+import getSectionState from 'Utilities/State/getSectionState';
+import { set, updateServerSideCollection } from '../baseActions';
+
+function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const sectionState = getSectionState(getState(), section, true);
+ const page = payload.page || sectionState.page || 1;
+
+ const data = Object.assign({ page },
+ _.pick(sectionState, [
+ 'pageSize',
+ 'sortDirection',
+ 'sortKey'
+ ]));
+
+ if (fetchDataAugmenter) {
+ fetchDataAugmenter(getState, payload, data);
+ }
+
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters
+ } = sectionState;
+
+ const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
+
+ selectedFilters.forEach((filter) => {
+ data[filter.key] = filter.value;
+ });
+
+ const promise = $.ajax({
+ url,
+ data
+ });
+
+ promise.done((response) => {
+ dispatch(batchActions([
+ updateServerSideCollection({ section, data: response }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+}
+
+export default createFetchServerSideCollectionHandler;
diff --git a/frontend/src/Store/Actions/Creators/createHandleActions.js b/frontend/src/Store/Actions/Creators/createHandleActions.js
new file mode 100644
index 000000000..c3315ce94
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createHandleActions.js
@@ -0,0 +1,143 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import {
+ SET,
+ UPDATE,
+ UPDATE_ITEM,
+ UPDATE_SERVER_SIDE_COLLECTION,
+ CLEAR_PENDING_CHANGES,
+ REMOVE_ITEM
+} from 'Store/Actions/baseActions';
+
+const blacklistedProperties = [
+ 'section',
+ 'id'
+];
+
+export default function createHandleActions(handlers, defaultState, section) {
+ return handleActions({
+
+ [SET]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = Object.assign(getSectionState(state, payloadSection),
+ _.omit(payload, blacklistedProperties));
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+
+ if (_.isArray(payload.data)) {
+ newState.items = payload.data;
+ } else {
+ newState.item = payload.data;
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE_ITEM]: function(state, { payload }) {
+ const {
+ section: payloadSection,
+ updateOnly = false,
+ ...otherProps
+ } = payload;
+
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+ const items = newState.items;
+ const index = _.findIndex(items, { id: payload.id });
+
+ newState.items = [...items];
+
+ // TODO: Move adding to it's own reducer
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...otherProps });
+ } else if (!updateOnly) {
+ newState.items.push({ ...otherProps });
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [CLEAR_PENDING_CHANGES]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+ newState.pendingChanges = {};
+
+ if (newState.hasOwnProperty('saveError')) {
+ newState.saveError = null;
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [REMOVE_ITEM]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE_SERVER_SIDE_COLLECTION]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const data = payload.data;
+ const newState = getSectionState(state, payloadSection);
+
+ const serverState = _.omit(data, ['records']);
+ const calculatedState = {
+ totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1),
+ items: data.records
+ };
+
+ return updateSectionState(state, payloadSection, Object.assign(newState, serverState, calculatedState));
+ }
+
+ return state;
+ },
+
+ ...handlers
+
+ }, defaultState);
+}
diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
new file mode 100644
index 000000000..6f353ed17
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
@@ -0,0 +1,45 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, removeItem } from '../baseActions';
+
+function createRemoveItemHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ const {
+ id,
+ ...queryParams
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const ajaxOptions = {
+ url: `${url}/${id}?${$.param(queryParams, true)}`,
+ method: 'DELETE'
+ };
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }),
+
+ removeItem({ section, id })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+
+ return promise;
+ };
+}
+
+export default createRemoveItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js
new file mode 100644
index 000000000..e63c9f993
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js
@@ -0,0 +1,43 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import { set, update } from '../baseActions';
+
+function createSaveHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSaving: true }));
+
+ const state = getSectionState(getState(), section, true);
+ const saveData = Object.assign({}, state.item, state.pendingChanges, payload);
+
+ const promise = $.ajax({
+ url,
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+}
+
+export default createSaveHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
new file mode 100644
index 000000000..6d555bf23
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
@@ -0,0 +1,70 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set, updateItem } from '../baseActions';
+
+const abortCurrentRequests = {};
+
+export function createCancelSaveProviderHandler(section) {
+ return function(getState, payload, dispatch) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+}
+
+function createSaveProviderHandler(section, url, options = {}) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSaving: true }));
+
+ const {
+ id,
+ queryParams = {},
+ ...otherPayload
+ } = payload;
+
+ const saveData = getProviderState({ id, ...otherPayload }, getState, section);
+
+ const ajaxOptions = {
+ url: `${url}?${$.param(queryParams, true)}`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ };
+
+ if (id) {
+ ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`;
+ ajaxOptions.method = 'PUT';
+ }
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortCurrentRequests[section] = abortRequest;
+
+ request.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section, ...data }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr.aborted ? null : xhr
+ }));
+ });
+ };
+}
+
+export default createSaveProviderHandler;
diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js
new file mode 100644
index 000000000..f81723769
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js
@@ -0,0 +1,52 @@
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import pages from 'Utilities/pages';
+import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler';
+import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler';
+import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler';
+import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler';
+
+function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) {
+ const actionHandlers = {};
+ const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH];
+ const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter);
+ actionHandlers[fetchHandlerType] = fetchHandler;
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) {
+ const handlerType = handlers[serverSideCollectionHandlers.SORT];
+ actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FILTER];
+ actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, fetchThunk);
+ }
+
+ return actionHandlers;
+}
+
+export default createServerSideCollectionHandlers;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
new file mode 100644
index 000000000..d7e476444
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
@@ -0,0 +1,10 @@
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionFilterHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, ...payload }));
+ dispatch(fetchHandler({ page: 1 }));
+ };
+}
+
+export default createSetServerSideCollectionFilterHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
new file mode 100644
index 000000000..12b21bb0d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
@@ -0,0 +1,35 @@
+import pages from 'Utilities/pages';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createSetServerSideCollectionPageHandler(section, page, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const sectionState = getSectionState(getState(), section, true);
+ const currentPage = sectionState.page || 1;
+ let nextPage = 0;
+
+ switch (page) {
+ case pages.FIRST:
+ nextPage = 1;
+ break;
+ case pages.PREVIOUS:
+ nextPage = currentPage - 1;
+ break;
+ case pages.NEXT:
+ nextPage = currentPage + 1;
+ break;
+ case pages.LAST:
+ nextPage = sectionState.totalPages;
+ break;
+ default:
+ nextPage = payload.page;
+ }
+
+ // If we prefer to update the page immediately we should
+ // set the page and not pass a page to the fetch handler.
+
+ // dispatch(set({ section, page: nextPage }));
+ dispatch(fetchHandler({ page: nextPage }));
+ };
+}
+
+export default createSetServerSideCollectionPageHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
new file mode 100644
index 000000000..fbd66e83e
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
@@ -0,0 +1,26 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import { sortDirections } from 'Helpers/Props';
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionSortHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const sectionState = getSectionState(getState(), section, true);
+ const sortKey = payload.sortKey || sectionState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === sectionState.sortKey) {
+ sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = sectionState.sortDirection;
+ }
+ }
+
+ dispatch(set({ section, sortKey, sortDirection }));
+ dispatch(fetchHandler());
+ };
+}
+
+export default createSetServerSideCollectionSortHandler;
diff --git a/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js
new file mode 100644
index 000000000..77deaec64
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js
@@ -0,0 +1,34 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set } from '../baseActions';
+
+function createTestAllProvidersHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isTestingAll: true }));
+
+ const ajaxOptions = {
+ url: `${url}/testall`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json'
+ };
+
+ const { request } = createAjaxRequest(ajaxOptions);
+
+ request.done((data) => {
+ dispatch(set({
+ section,
+ isTestingAll: false,
+ saveError: null
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isTestingAll: false
+ }));
+ });
+ };
+}
+
+export default createTestAllProvidersHandler;
diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
new file mode 100644
index 000000000..ca26883fb
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
@@ -0,0 +1,52 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set } from '../baseActions';
+
+const abortCurrentRequests = {};
+
+export function createCancelTestProviderHandler(section) {
+ return function(getState, payload, dispatch) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+}
+
+function createTestProviderHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isTesting: true }));
+
+ const testData = getProviderState(payload, getState, section);
+
+ const ajaxOptions = {
+ url: `${url}/test`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(testData)
+ };
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortCurrentRequests[section] = abortRequest;
+
+ request.done((data) => {
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: null
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: xhr.aborted ? null : xhr
+ }));
+ });
+ };
+}
+
+export default createTestProviderHandler;
diff --git a/frontend/src/Store/Actions/Settings/delayProfiles.js b/frontend/src/Store/Actions/Settings/delayProfiles.js
new file mode 100644
index 000000000..68232e510
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/delayProfiles.js
@@ -0,0 +1,103 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import { update } from 'Store/Actions/baseActions';
+
+//
+// Variables
+
+const section = 'settings.delayProfiles';
+
+//
+// Actions Types
+
+export const FETCH_DELAY_PROFILES = 'settings/delayProfiles/fetchDelayProfiles';
+export const FETCH_DELAY_PROFILE_SCHEMA = 'settings/delayProfiles/fetchDelayProfileSchema';
+export const SAVE_DELAY_PROFILE = 'settings/delayProfiles/saveDelayProfile';
+export const DELETE_DELAY_PROFILE = 'settings/delayProfiles/deleteDelayProfile';
+export const REORDER_DELAY_PROFILE = 'settings/delayProfiles/reorderDelayProfile';
+export const SET_DELAY_PROFILE_VALUE = 'settings/delayProfiles/setDelayProfileValue';
+
+//
+// Action Creators
+
+export const fetchDelayProfiles = createThunk(FETCH_DELAY_PROFILES);
+export const fetchDelayProfileSchema = createThunk(FETCH_DELAY_PROFILE_SCHEMA);
+export const saveDelayProfile = createThunk(SAVE_DELAY_PROFILE);
+export const deleteDelayProfile = createThunk(DELETE_DELAY_PROFILE);
+export const reorderDelayProfile = createThunk(REORDER_DELAY_PROFILE);
+
+export const setDelayProfileValue = createAction(SET_DELAY_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DELAY_PROFILES]: createFetchHandler(section, '/delayprofile'),
+ [FETCH_DELAY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/delayprofile/schema'),
+
+ [SAVE_DELAY_PROFILE]: createSaveProviderHandler(section, '/delayprofile'),
+ [DELETE_DELAY_PROFILE]: createRemoveItemHandler(section, '/delayprofile'),
+
+ [REORDER_DELAY_PROFILE]: (getState, payload, dispatch) => {
+ const { id, moveIndex } = payload;
+ const moveOrder = moveIndex + 1;
+ const delayProfiles = getState().settings.delayProfiles.items;
+ const moving = _.find(delayProfiles, { id });
+
+ // Don't move if the order hasn't changed
+ if (moving.order === moveOrder) {
+ return;
+ }
+
+ const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null;
+ const afterQueryParam = after ? `after=${after.id}` : '';
+
+ const promise = $.ajax({
+ method: 'PUT',
+ url: `/delayprofile/reorder/${id}?${afterQueryParam}`
+ });
+
+ promise.done((data) => {
+ dispatch(update({ section, data }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/downloadClientOptions.js b/frontend/src/Store/Actions/Settings/downloadClientOptions.js
new file mode 100644
index 000000000..6d4a3954d
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/downloadClientOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.downloadClientOptions';
+
+//
+// Actions Types
+
+export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS';
+export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE';
+export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS';
+
+//
+// Action Creators
+
+export const fetchDownloadClientOptions = createThunk(FETCH_DOWNLOAD_CLIENT_OPTIONS);
+export const saveDownloadClientOptions = createThunk(SAVE_DOWNLOAD_CLIENT_OPTIONS);
+export const setDownloadClientOptionsValue = createAction(SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler(section, '/config/downloadclient'),
+ [SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler(section, '/config/downloadclient')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js
new file mode 100644
index 000000000..a268053f7
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/downloadClients.js
@@ -0,0 +1,117 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.downloadClients';
+
+//
+// Actions Types
+
+export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients';
+export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema';
+export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema';
+export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue';
+export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue';
+export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient';
+export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient';
+export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient';
+export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
+export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
+export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
+
+//
+// Action Creators
+
+export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS);
+export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA);
+export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA);
+
+export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT);
+export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT);
+export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
+export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
+export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
+export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
+
+export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'),
+ [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'),
+
+ [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'),
+ [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section),
+ [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
+ [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
+ [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
+ [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section),
+ [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.enable = true;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js
new file mode 100644
index 000000000..f5e8c277e
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/general.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.general';
+
+//
+// Actions Types
+
+export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings';
+export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue';
+export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings';
+
+//
+// Action Creators
+
+export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS);
+export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS);
+export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'),
+ [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js
new file mode 100644
index 000000000..53fb21651
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexerOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.indexerOptions';
+
+//
+// Actions Types
+
+export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions';
+export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions';
+export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue';
+
+//
+// Action Creators
+
+export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS);
+export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS);
+export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'),
+ [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js
new file mode 100644
index 000000000..ddab7c154
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexers.js
@@ -0,0 +1,119 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.indexers';
+
+//
+// Actions Types
+
+export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers';
+export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema';
+export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema';
+export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue';
+export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue';
+export const SAVE_INDEXER = 'settings/indexers/saveIndexer';
+export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer';
+export const DELETE_INDEXER = 'settings/indexers/deleteIndexer';
+export const TEST_INDEXER = 'settings/indexers/testIndexer';
+export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
+export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
+
+//
+// Action Creators
+
+export const fetchIndexers = createThunk(FETCH_INDEXERS);
+export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA);
+export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA);
+
+export const saveIndexer = createThunk(SAVE_INDEXER);
+export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER);
+export const deleteIndexer = createThunk(DELETE_INDEXER);
+export const testIndexer = createThunk(TEST_INDEXER);
+export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
+export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
+
+export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'),
+ [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'),
+
+ [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'),
+ [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section),
+ [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
+ [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
+ [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
+ [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_VALUE]: createSetSettingValueReducer(section),
+ [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_INDEXER_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.enableRss = selectedSchema.supportsRss;
+ selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch;
+ selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/languageProfiles.js b/frontend/src/Store/Actions/Settings/languageProfiles.js
new file mode 100644
index 000000000..49fe9825b
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/languageProfiles.js
@@ -0,0 +1,97 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.languageProfiles';
+
+//
+// Actions Types
+
+export const FETCH_LANGUAGE_PROFILES = 'settings/languageProfiles/fetchLanguageProfiles';
+export const FETCH_LANGUAGE_PROFILE_SCHEMA = 'settings/languageProfiles/fetchLanguageProfileSchema';
+export const SAVE_LANGUAGE_PROFILE = 'settings/languageProfiles/saveLanguageProfile';
+export const DELETE_LANGUAGE_PROFILE = 'settings/languageProfiles/deleteLanguageProfile';
+export const SET_LANGUAGE_PROFILE_VALUE = 'settings/languageProfiles/setLanguageProfileValue';
+export const CLONE_LANGUAGE_PROFILE = 'settings/languageProfiles/cloneLanguageProfile';
+
+//
+// Action Creators
+
+export const fetchLanguageProfiles = createThunk(FETCH_LANGUAGE_PROFILES);
+export const fetchLanguageProfileSchema = createThunk(FETCH_LANGUAGE_PROFILE_SCHEMA);
+export const saveLanguageProfile = createThunk(SAVE_LANGUAGE_PROFILE);
+export const deleteLanguageProfile = createThunk(DELETE_LANGUAGE_PROFILE);
+
+export const setLanguageProfileValue = createAction(SET_LANGUAGE_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneLanguageProfile = createAction(CLONE_LANGUAGE_PROFILE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_LANGUAGE_PROFILES]: createFetchHandler(section, '/languageprofile'),
+ [FETCH_LANGUAGE_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/languageprofile/schema'),
+ [SAVE_LANGUAGE_PROFILE]: createSaveProviderHandler(section, '/languageprofile'),
+ [DELETE_LANGUAGE_PROFILE]: createRemoveItemHandler(section, '/languageprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_LANGUAGE_PROFILE]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/mediaManagement.js b/frontend/src/Store/Actions/Settings/mediaManagement.js
new file mode 100644
index 000000000..4ae9eba0c
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/mediaManagement.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.mediaManagement';
+
+//
+// Actions Types
+
+export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/fetchMediaManagementSettings';
+export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/saveMediaManagementSettings';
+export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'settings/mediaManagement/setMediaManagementSettingsValue';
+
+//
+// Action Creators
+
+export const fetchMediaManagementSettings = createThunk(FETCH_MEDIA_MANAGEMENT_SETTINGS);
+export const saveMediaManagementSettings = createThunk(SAVE_MEDIA_MANAGEMENT_SETTINGS);
+export const setMediaManagementSettingsValue = createAction(SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler(section, '/config/mediamanagement'),
+ [SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler(section, '/config/mediamanagement')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/metadata.js b/frontend/src/Store/Actions/Settings/metadata.js
new file mode 100644
index 000000000..ed5e0aa86
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/metadata.js
@@ -0,0 +1,75 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+
+//
+// Variables
+
+const section = 'settings.metadata';
+
+//
+// Actions Types
+
+export const FETCH_METADATA = 'settings/metadata/fetchMetadata';
+export const SET_METADATA_VALUE = 'settings/metadata/setMetadataValue';
+export const SET_METADATA_FIELD_VALUE = 'settings/metadata/setMetadataFieldValue';
+export const SAVE_METADATA = 'settings/metadata/saveMetadata';
+
+//
+// Action Creators
+
+export const fetchMetadata = createThunk(FETCH_METADATA);
+export const saveMetadata = createThunk(SAVE_METADATA);
+
+export const setMetadataValue = createAction(SET_METADATA_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setMetadataFieldValue = createAction(SET_METADATA_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_METADATA]: createFetchHandler(section, '/metadata'),
+ [SAVE_METADATA]: createSaveProviderHandler(section, '/metadata')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_METADATA_VALUE]: createSetSettingValueReducer(section),
+ [SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/naming.js b/frontend/src/Store/Actions/Settings/naming.js
new file mode 100644
index 000000000..27add8309
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/naming.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.naming';
+
+//
+// Actions Types
+
+export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings';
+export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings';
+export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue';
+
+//
+// Action Creators
+
+export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS);
+export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS);
+export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'),
+ [SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/namingExamples.js b/frontend/src/Store/Actions/Settings/namingExamples.js
new file mode 100644
index 000000000..e3f2ae01c
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/namingExamples.js
@@ -0,0 +1,79 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk } from 'Store/thunks';
+import { set, update } from 'Store/Actions/baseActions';
+
+//
+// Variables
+
+const section = 'settings.namingExamples';
+
+//
+// Actions Types
+
+export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples';
+
+//
+// Action Creators
+
+export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const naming = getState().settings.naming;
+
+ const promise = $.ajax({
+ url: '/config/naming/examples',
+ data: Object.assign({}, naming.item, naming.pendingChanges)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {}
+
+};
diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js
new file mode 100644
index 000000000..b2c28dac9
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/notifications.js
@@ -0,0 +1,115 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.notifications';
+
+//
+// Actions Types
+
+export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications';
+export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema';
+export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema';
+export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue';
+export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue';
+export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification';
+export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification';
+export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification';
+export const TEST_NOTIFICATION = 'settings/notifications/testNotification';
+export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification';
+
+//
+// Action Creators
+
+export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS);
+export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA);
+export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA);
+
+export const saveNotification = createThunk(SAVE_NOTIFICATION);
+export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION);
+export const deleteNotification = createThunk(DELETE_NOTIFICATION);
+export const testNotification = createThunk(TEST_NOTIFICATION);
+export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION);
+
+export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'),
+ [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'),
+
+ [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'),
+ [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section),
+ [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'),
+ [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'),
+ [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section)
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section),
+ [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.onGrab = selectedSchema.supportsOnGrab;
+ selectedSchema.onDownload = selectedSchema.supportsOnDownload;
+ selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
+ selectedSchema.onRename = selectedSchema.supportsOnRename;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js
new file mode 100644
index 000000000..fb6572f45
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js
@@ -0,0 +1,135 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk } from 'Store/thunks';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import { clearPendingChanges, set, update } from 'Store/Actions/baseActions';
+
+//
+// Variables
+
+const section = 'settings.qualityDefinitions';
+
+//
+// Actions Types
+
+export const FETCH_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/fetchQualityDefinitions';
+export const SAVE_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/saveQualityDefinitions';
+export const SET_QUALITY_DEFINITION_VALUE = 'settings/qualityDefinitions/setQualityDefinitionValue';
+
+//
+// Action Creators
+
+export const fetchQualityDefinitions = createThunk(FETCH_QUALITY_DEFINITIONS);
+export const saveQualityDefinitions = createThunk(SAVE_QUALITY_DEFINITIONS);
+
+export const setQualityDefinitionValue = createAction(SET_QUALITY_DEFINITION_VALUE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_QUALITY_DEFINITIONS]: createFetchHandler(section, '/qualitydefinition'),
+ [SAVE_QUALITY_DEFINITIONS]: createSaveHandler(section, '/qualitydefinition'),
+
+ [SAVE_QUALITY_DEFINITIONS]: function(getState, payload, dispatch) {
+ const qualityDefinitions = getState().settings.qualityDefinitions;
+
+ const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => {
+ const id = parseInt(key);
+ const pendingChanges = qualityDefinitions.pendingChanges[id] || {};
+ const item = _.find(qualityDefinitions.items, { id });
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ // If there is nothing to save don't bother isSaving
+ if (!upatedDefinitions || !upatedDefinitions.length) {
+ return;
+ }
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ method: 'PUT',
+ url: '/qualityDefinition/update',
+ data: JSON.stringify(upatedDefinitions)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ }),
+
+ update({ section, data }),
+ clearPendingChanges({ section })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) {
+ const { id, name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = _.cloneDeep(newState.pendingChanges);
+
+ const pendingState = newState.pendingChanges[id] || {};
+ const currentValue = _.find(newState.items, { id })[name];
+
+ if (currentValue === value) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = value;
+ }
+
+ if (_.isEmpty(pendingState)) {
+ delete newState.pendingChanges[id];
+ } else {
+ newState.pendingChanges[id] = pendingState;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js
new file mode 100644
index 000000000..6fdc204a0
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js
@@ -0,0 +1,97 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.qualityProfiles';
+
+//
+// Actions Types
+
+export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles';
+export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema';
+export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile';
+export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile';
+export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue';
+export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile';
+
+//
+// Action Creators
+
+export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES);
+export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA);
+export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE);
+export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE);
+
+export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'),
+ [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'),
+ [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'),
+ [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_QUALITY_PROFILE]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/releaseProfiles.js b/frontend/src/Store/Actions/Settings/releaseProfiles.js
new file mode 100644
index 000000000..339e732f6
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/releaseProfiles.js
@@ -0,0 +1,71 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.releaseProfiles';
+
+//
+// Actions Types
+
+export const FETCH_RELEASE_PROFILES = 'settings/releaseProfiles/fetchReleaseProfiles';
+export const SAVE_RELEASE_PROFILE = 'settings/releaseProfiles/saveReleaseProfile';
+export const DELETE_RELEASE_PROFILE = 'settings/releaseProfiles/deleteReleaseProfile';
+export const SET_RELEASE_PROFILE_VALUE = 'settings/releaseProfiles/setReleaseProfileValue';
+
+//
+// Action Creators
+
+export const fetchReleaseProfiles = createThunk(FETCH_RELEASE_PROFILES);
+export const saveReleaseProfile = createThunk(SAVE_RELEASE_PROFILE);
+export const deleteReleaseProfile = createThunk(DELETE_RELEASE_PROFILE);
+
+export const setReleaseProfileValue = createAction(SET_RELEASE_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'),
+
+ [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'),
+
+ [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js
new file mode 100644
index 000000000..3cfcc7f1f
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js
@@ -0,0 +1,69 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.remotePathMappings';
+
+//
+// Actions Types
+
+export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings';
+export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping';
+export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping';
+export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue';
+
+//
+// Action Creators
+
+export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS);
+export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING);
+export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING);
+
+export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'),
+ [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'),
+ [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js
new file mode 100644
index 000000000..97d7223fd
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/ui.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.ui';
+
+//
+// Actions Types
+
+export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings';
+export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE';
+export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS';
+
+//
+// Action Creators
+
+export const fetchUISettings = createThunk(FETCH_UI_SETTINGS);
+export const saveUISettings = createThunk(SAVE_UI_SETTINGS);
+export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'),
+ [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js
new file mode 100644
index 000000000..a0c784a7d
--- /dev/null
+++ b/frontend/src/Store/Actions/actionTypes.js
@@ -0,0 +1,21 @@
+//
+// App
+
+export const SHOW_MESSAGE = 'SHOW_MESSAGE';
+export const HIDE_MESSAGE = 'HIDE_MESSAGE';
+export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS';
+export const SET_VERSION = 'SET_VERSION';
+export const SET_APP_VALUE = 'SET_APP_VALUE';
+export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE';
+
+//
+// Settings
+
+export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings';
+export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue';
+export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings';
+
+//
+// Languages
+
+export const FETCH_LANGUAGES = 'FETCH_LANGUAGES';
diff --git a/frontend/src/Store/Actions/addSeriesActions.js b/frontend/src/Store/Actions/addSeriesActions.js
new file mode 100644
index 000000000..469061820
--- /dev/null
+++ b/frontend/src/Store/Actions/addSeriesActions.js
@@ -0,0 +1,181 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import monitorOptions from 'Utilities/Series/monitorOptions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getNewSeries from 'Utilities/Series/getNewSeries';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'addSeries';
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isAdding: false,
+ isAdded: false,
+ addError: null,
+ items: [],
+
+ defaults: {
+ rootFolderPath: '',
+ monitor: monitorOptions[0].key,
+ qualityProfileId: 0,
+ languageProfileId: 0,
+ seriesType: 'standard',
+ seasonFolder: true,
+ tags: []
+ }
+};
+
+export const persistState = [
+ 'addSeries.defaults'
+];
+
+//
+// Actions Types
+
+export const LOOKUP_SERIES = 'addSeries/lookupSeries';
+export const ADD_SERIES = 'addSeries/addSeries';
+export const SET_ADD_SERIES_VALUE = 'addSeries/setAddSeriesValue';
+export const CLEAR_ADD_SERIES = 'addSeries/clearAddSeries';
+export const SET_ADD_SERIES_DEFAULT = 'addSeries/setAddSeriesDefault';
+
+//
+// Action Creators
+
+export const lookupSeries = createThunk(LOOKUP_SERIES);
+export const addSeries = createThunk(ADD_SERIES);
+export const clearAddSeries = createAction(CLEAR_ADD_SERIES);
+export const setAddSeriesDefault = createAction(SET_ADD_SERIES_DEFAULT);
+
+export const setAddSeriesValue = createAction(SET_ADD_SERIES_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [LOOKUP_SERIES]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ if (abortCurrentRequest) {
+ abortCurrentRequest();
+ }
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/series/lookup',
+ data: {
+ term: payload.term
+ }
+ });
+
+ abortCurrentRequest = abortRequest;
+
+ request.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr.aborted ? null : xhr
+ }));
+ });
+ },
+
+ [ADD_SERIES]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isAdding: true }));
+
+ const tvdbId = payload.tvdbId;
+ const items = getState().addSeries.items;
+ const newSeries = getNewSeries(_.cloneDeep(_.find(items, { tvdbId })), payload);
+
+ const promise = $.ajax({
+ url: '/series',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(newSeries)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section: 'series', ...data }),
+
+ set({
+ section,
+ isAdding: false,
+ isAdded: true,
+ addError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isAdding: false,
+ isAdded: false,
+ addError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ADD_SERIES_VALUE]: createSetSettingValueReducer(section),
+
+ [SET_ADD_SERIES_DEFAULT]: function(state, { payload }) {
+ const newState = getSectionState(state, section);
+
+ newState.defaults = {
+ ...newState.defaults,
+ ...payload
+ };
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [CLEAR_ADD_SERIES]: function(state) {
+ const {
+ defaults,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js
new file mode 100644
index 000000000..81c61ca80
--- /dev/null
+++ b/frontend/src/Store/Actions/appActions.js
@@ -0,0 +1,135 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createHandleActions from './Creators/createHandleActions';
+
+function getDimensions(width, height) {
+ const dimensions = {
+ width,
+ height,
+ isExtraSmallScreen: width <= 480,
+ isSmallScreen: width <= 768,
+ isMediumScreen: width <= 992,
+ isLargeScreen: width <= 1200
+ };
+
+ return dimensions;
+}
+
+//
+// Variables
+
+export const section = 'app';
+const messagesSection = 'app.messages';
+
+//
+// State
+
+export const defaultState = {
+ dimensions: getDimensions(window.innerWidth, window.innerHeight),
+ messages: {
+ items: []
+ },
+ version: window.Sonarr.version,
+ isUpdated: false,
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false,
+ isRestarting: false,
+ isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
+};
+
+//
+// Action Types
+
+export const SHOW_MESSAGE = 'app/showMessage';
+export const HIDE_MESSAGE = 'app/hideMessage';
+export const SAVE_DIMENSIONS = 'app/saveDimensions';
+export const SET_VERSION = 'app/setVersion';
+export const SET_APP_VALUE = 'app/setAppValue';
+export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible';
+
+//
+// Action Creators
+
+export const saveDimensions = createAction(SAVE_DIMENSIONS);
+export const setVersion = createAction(SET_VERSION);
+export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE);
+export const setAppValue = createAction(SET_APP_VALUE);
+export const showMessage = createAction(SHOW_MESSAGE);
+export const hideMessage = createAction(HIDE_MESSAGE);
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SAVE_DIMENSIONS]: function(state, { payload }) {
+ const {
+ width,
+ height
+ } = payload;
+
+ const dimensions = getDimensions(width, height);
+
+ return Object.assign({}, state, { dimensions });
+ },
+
+ [SHOW_MESSAGE]: function(state, { payload }) {
+ const newState = getSectionState(state, messagesSection);
+ const items = newState.items;
+ const index = _.findIndex(items, { id: payload.id });
+
+ newState.items = [...items];
+
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...payload });
+ } else {
+ newState.items.push({ ...payload });
+ }
+
+ return updateSectionState(state, messagesSection, newState);
+ },
+
+ [HIDE_MESSAGE]: function(state, { payload }) {
+ const newState = getSectionState(state, messagesSection);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ return updateSectionState(state, messagesSection, newState);
+ },
+
+ [SET_APP_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [SET_VERSION]: function(state, { payload }) {
+ const version = payload.version;
+
+ const newState = {
+ version
+ };
+
+ if (state.version !== version) {
+ newState.isUpdated = true;
+ }
+
+ return Object.assign({}, state, newState);
+ },
+
+ [SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) {
+ const newState = {
+ isSidebarVisible: payload.isSidebarVisible
+ };
+
+ return Object.assign({}, state, newState);
+ }
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js
new file mode 100644
index 000000000..37be3e0d2
--- /dev/null
+++ b/frontend/src/Store/Actions/baseActions.js
@@ -0,0 +1,29 @@
+import { createAction } from 'redux-actions';
+
+//
+// Action Types
+
+export const SET = 'base/set';
+
+export const UPDATE = 'base/update';
+export const UPDATE_ITEM = 'base/updateItem';
+export const UPDATE_SERVER_SIDE_COLLECTION = 'base/updateServerSideCollection';
+
+export const SET_SETTING_VALUE = 'base/setSettingValue';
+export const CLEAR_PENDING_CHANGES = 'base/clearPendingChanges';
+
+export const REMOVE_ITEM = 'base/removeItem';
+
+//
+// Action Creators
+
+export const set = createAction(SET);
+
+export const update = createAction(UPDATE);
+export const updateItem = createAction(UPDATE_ITEM);
+export const updateServerSideCollection = createAction(UPDATE_SERVER_SIDE_COLLECTION);
+
+export const setSettingValue = createAction(SET_SETTING_VALUE);
+export const clearPendingChanges = createAction(CLEAR_PENDING_CHANGES);
+
+export const removeItem = createAction(REMOVE_ITEM);
diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js
new file mode 100644
index 000000000..d6fbfb99b
--- /dev/null
+++ b/frontend/src/Store/Actions/blacklistActions.js
@@ -0,0 +1,144 @@
+import { createAction } from 'redux-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { sortDirections } from 'Helpers/Props';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+
+//
+// Variables
+
+export const section = 'blacklist';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'series.sortTitle',
+ label: 'Series Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'blacklist.pageSize',
+ 'blacklist.sortKey',
+ 'blacklist.sortDirection',
+ 'blacklist.columns'
+];
+
+//
+// Action Types
+
+export const FETCH_BLACKLIST = 'blacklist/fetchBlacklist';
+export const GOTO_FIRST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistFirstPage';
+export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPreviousPage';
+export const GOTO_NEXT_BLACKLIST_PAGE = 'blacklist/gotoBlacklistNextPage';
+export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage';
+export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage';
+export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort';
+export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption';
+export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist';
+export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist';
+
+//
+// Action Creators
+
+export const fetchBlacklist = createThunk(FETCH_BLACKLIST);
+export const gotoBlacklistFirstPage = createThunk(GOTO_FIRST_BLACKLIST_PAGE);
+export const gotoBlacklistPreviousPage = createThunk(GOTO_PREVIOUS_BLACKLIST_PAGE);
+export const gotoBlacklistNextPage = createThunk(GOTO_NEXT_BLACKLIST_PAGE);
+export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE);
+export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE);
+export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT);
+export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION);
+export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST);
+export const clearBlacklist = createAction(CLEAR_BLACKLIST);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...createServerSideCollectionHandlers(
+ section,
+ '/blacklist',
+ fetchBlacklist,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_BLACKLIST,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT
+ }),
+
+ [REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist')
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_BLACKLIST]: createClearReducer('history', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js
new file mode 100644
index 000000000..83c28036a
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActions.js
@@ -0,0 +1,391 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import moment from 'moment';
+import { filterTypes } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import * as calendarViews from 'Calendar/calendarViews';
+import * as commandNames from 'Commands/commandNames';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+import { executeCommandHelper } from './commandActions';
+
+//
+// Variables
+
+export const section = 'calendar';
+
+const viewRanges = {
+ [calendarViews.DAY]: 'day',
+ [calendarViews.WEEK]: 'week',
+ [calendarViews.MONTH]: 'month',
+ [calendarViews.FORECAST]: 'day'
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ start: null,
+ end: null,
+ dates: [],
+ dayCount: 7,
+ view: window.innerWidth > 768 ? 'week' : 'day',
+ error: null,
+ items: [],
+ searchMissingCommandId: null,
+
+ options: {
+ collapseMultipleEpisodes: false,
+ showEpisodeInformation: true,
+ showFinaleIcon: false,
+ showSpecialIcon: false,
+ showCutoffUnmetIcon: false
+ },
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'monitored',
+ label: 'Monitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+
+ ]
+};
+
+export const persistState = [
+ 'calendar.view',
+ 'calendar.selectedFilterKey',
+ 'calendar.options'
+];
+
+//
+// Actions Types
+
+export const FETCH_CALENDAR = 'calendar/fetchCalendar';
+export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount';
+export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter';
+export const SET_CALENDAR_VIEW = 'calendar/setCalendarView';
+export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday';
+export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange';
+export const CLEAR_CALENDAR = 'calendar/clearCalendar';
+export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption';
+export const SEARCH_MISSING = 'calendar/searchMissing';
+export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange';
+
+//
+// Helpers
+
+function getDays(start, end) {
+ const startTime = moment(start);
+ const endTime = moment(end);
+ const difference = endTime.diff(startTime, 'days');
+
+ // Difference is one less than the number of days we need to account for.
+ return _.times(difference + 1, (i) => {
+ return startTime.clone().add(i, 'days').toISOString();
+ });
+}
+
+function getDates(time, view, firstDayOfWeek, dayCount) {
+ const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek';
+
+ let start = time.clone().startOf('day');
+ let end = time.clone().endOf('day');
+
+ if (view === calendarViews.WEEK) {
+ start = time.clone().startOf(weekName);
+ end = time.clone().endOf(weekName);
+ }
+
+ if (view === calendarViews.FORECAST) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(dayCount - 2, 'days').endOf('day');
+ }
+
+ if (view === calendarViews.MONTH) {
+ start = time.clone().startOf('month').startOf(weekName);
+ end = time.clone().endOf('month').endOf(weekName);
+ }
+
+ if (view === calendarViews.AGENDA) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(1, 'month').endOf('day');
+ }
+
+ return {
+ start: start.toISOString(),
+ end: end.toISOString(),
+ time: time.toISOString(),
+ dates: getDays(start, end)
+ };
+}
+
+function getPopulatableRange(startDate, endDate, view) {
+ switch (view) {
+ case calendarViews.DAY:
+ return {
+ start: moment(startDate).subtract(1, 'day').toISOString(),
+ end: moment(endDate).add(1, 'day').toISOString()
+ };
+ case calendarViews.WEEK:
+ case calendarViews.FORECAST:
+ return {
+ start: moment(startDate).subtract(1, 'week').toISOString(),
+ end: moment(endDate).add(1, 'week').toISOString()
+ };
+ default:
+ return {
+ start: startDate,
+ end: endDate
+ };
+ }
+}
+
+function isRangePopulated(start, end, state) {
+ const {
+ start: currentStart,
+ end: currentEnd,
+ view: currentView
+ } = state;
+
+ if (!currentStart || !currentEnd) {
+ return false;
+ }
+
+ const {
+ start: currentPopulatedStart,
+ end: currentPopulatedEnd
+ } = getPopulatableRange(currentStart, currentEnd, currentView);
+
+ if (
+ moment(start).isAfter(currentPopulatedStart) &&
+ moment(start).isBefore(currentPopulatedEnd)
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+//
+// Action Creators
+
+export const fetchCalendar = createThunk(FETCH_CALENDAR);
+export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT);
+export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER);
+export const setCalendarView = createThunk(SET_CALENDAR_VIEW);
+export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY);
+export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE);
+export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE);
+export const clearCalendar = createAction(CLEAR_CALENDAR);
+export const setCalendarOption = createAction(SET_CALENDAR_OPTION);
+export const searchMissing = createThunk(SEARCH_MISSING);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_CALENDAR]: function(getState, payload, dispatch) {
+ const state = getState();
+ const calendar = state.calendar;
+ const unmonitored = calendar.selectedFilterKey === 'all';
+
+ const {
+ time = calendar.time,
+ view = calendar.view
+ } = payload;
+
+ const dayCount = state.calendar.dayCount;
+ const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount);
+ const { start, end } = getPopulatableRange(dates.start, dates.end, view);
+ const isPrePopulated = isRangePopulated(start, end, state.calendar);
+
+ const basesAttrs = {
+ section,
+ isFetching: true
+ };
+
+ const attrs = isPrePopulated ?
+ {
+ view,
+ ...basesAttrs,
+ ...dates
+ } :
+ basesAttrs;
+
+ dispatch(set(attrs));
+
+ const promise = $.ajax({
+ url: '/calendar',
+ data: {
+ unmonitored,
+ start,
+ end
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ view,
+ ...dates,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) {
+ if (payload.dayCount === getState().calendar.dayCount) {
+ return;
+ }
+
+ dispatch(set({
+ section,
+ dayCount: payload.dayCount
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ selectedFilterKey: payload.selectedFilterKey
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) {
+ const state = getState();
+ const view = payload.view;
+ const time = view === calendarViews.FORECAST || calendarViews.AGENDA ?
+ moment() :
+ state.calendar.time;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) {
+ const state = getState();
+ const view = state.calendar.view;
+ const time = moment();
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).subtract(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).add(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SEARCH_MISSING]: function(getState, payload, dispatch) {
+ const { episodeIds } = payload;
+
+ const commandPayload = {
+ name: commandNames.EPISODE_SEARCH,
+ episodeIds
+ };
+
+ executeCommandHelper(commandPayload, dispatch).then((data) => {
+ dispatch(set({
+ section,
+ searchMissingCommandId: data.id
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_CALENDAR]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }),
+
+ [SET_CALENDAR_OPTION]: function(state, { payload }) {
+ const options = state.options;
+
+ return {
+ ...state,
+ options: {
+ ...options,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js
new file mode 100644
index 000000000..d506566f7
--- /dev/null
+++ b/frontend/src/Store/Actions/captchaActions.js
@@ -0,0 +1,119 @@
+import { createAction } from 'redux-actions';
+import requestAction from 'Utilities/requestAction';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'captcha';
+
+//
+// State
+
+export const defaultState = {
+ refreshing: false,
+ token: null,
+ siteKey: null,
+ secretToken: null,
+ ray: null,
+ stoken: null,
+ responseUrl: null
+};
+
+//
+// Actions Types
+
+export const REFRESH_CAPTCHA = 'captcha/refreshCaptcha';
+export const GET_CAPTCHA_COOKIE = 'captcha/getCaptchaCookie';
+export const SET_CAPTCHA_VALUE = 'captcha/setCaptchaValue';
+export const RESET_CAPTCHA = 'captcha/resetCaptcha';
+
+//
+// Action Creators
+
+export const refreshCaptcha = createThunk(REFRESH_CAPTCHA);
+export const getCaptchaCookie = createThunk(GET_CAPTCHA_COOKIE);
+export const setCaptchaValue = createAction(SET_CAPTCHA_VALUE);
+export const resetCaptcha = createAction(RESET_CAPTCHA);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [REFRESH_CAPTCHA]: function(getState, payload, dispatch) {
+ const actionPayload = {
+ action: 'checkCaptcha',
+ ...payload
+ };
+
+ dispatch(setCaptchaValue({
+ refreshing: true
+ }));
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ if (!data.captchaRequest) {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ }
+
+ dispatch(setCaptchaValue({
+ refreshing: false,
+ ...data.captchaRequest
+ }));
+ });
+
+ promise.fail(() => {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ });
+ },
+
+ [GET_CAPTCHA_COOKIE]: function(getState, payload, dispatch) {
+ const state = getState().captcha;
+
+ const queryParams = {
+ responseUrl: state.responseUrl,
+ ray: state.ray,
+ captchaResponse: payload.captchaResponse
+ };
+
+ const actionPayload = {
+ action: 'getCaptchaCookie',
+ queryParams,
+ ...payload
+ };
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ dispatch(setCaptchaValue({
+ token: data.captchaToken
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_CAPTCHA_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [RESET_CAPTCHA]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState);
diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js
new file mode 100644
index 000000000..5a7baf58a
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActions.js
@@ -0,0 +1,215 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { isSameCommand } from 'Utilities/Command';
+import { messageTypes } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { showMessage, hideMessage } from './appActions';
+import { updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'commands';
+
+let lastCommand = null;
+let lastCommandTimeout = null;
+const removeCommandTimeoutIds = {};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ handlers: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_COMMANDS = 'commands/fetchCommands';
+export const EXECUTE_COMMAND = 'commands/executeCommand';
+export const CANCEL_COMMAND = 'commands/cancelCommand';
+export const ADD_COMMAND = 'commands/updateCommand';
+export const UPDATE_COMMAND = 'commands/finishCommand';
+export const FINISH_COMMAND = 'commands/addCommand';
+export const REMOVE_COMMAND = 'commands/removeCommand';
+
+//
+// Action Creators
+
+export const fetchCommands = createThunk(FETCH_COMMANDS);
+export const executeCommand = createThunk(EXECUTE_COMMAND);
+export const cancelCommand = createThunk(CANCEL_COMMAND);
+export const updateCommand = createThunk(UPDATE_COMMAND);
+export const finishCommand = createThunk(FINISH_COMMAND);
+export const addCommand = createAction(ADD_COMMAND);
+export const removeCommand = createAction(REMOVE_COMMAND);
+
+//
+// Helpers
+
+function showCommandMessage(payload, dispatch) {
+ const {
+ id,
+ name,
+ trigger,
+ message,
+ body = {},
+ status
+ } = payload;
+
+ const {
+ sendUpdatesToClient,
+ suppressMessages
+ } = body;
+
+ if (!message || !body || !sendUpdatesToClient || suppressMessages) {
+ return;
+ }
+
+ let type = messageTypes.INFO;
+ let hideAfter = 0;
+
+ if (status === 'completed') {
+ type = messageTypes.SUCCESS;
+ hideAfter = 4;
+ } else if (status === 'failed') {
+ type = messageTypes.ERROR;
+ hideAfter = trigger === 'manual' ? 10 : 4;
+ }
+
+ dispatch(showMessage({
+ id,
+ name,
+ message,
+ type,
+ hideAfter
+ }));
+}
+
+function scheduleRemoveCommand(command, dispatch) {
+ const {
+ id,
+ status
+ } = command;
+
+ if (status === 'queued') {
+ return;
+ }
+
+ const timeoutId = removeCommandTimeoutIds[id];
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ removeCommandTimeoutIds[id] = setTimeout(() => {
+ dispatch(batchActions([
+ removeCommand({ section: 'commands', id }),
+ hideMessage({ id })
+ ]));
+
+ delete removeCommandTimeoutIds[id];
+ }, 60000 * 5);
+}
+
+export function executeCommandHelper( payload, dispatch) {
+ // TODO: show a message for the user
+ if (lastCommand && isSameCommand(lastCommand, payload)) {
+ console.warn('Please wait at least 5 seconds before running this command again');
+ }
+
+ lastCommand = payload;
+
+ // clear last command after 5 seconds.
+ if (lastCommandTimeout) {
+ clearTimeout(lastCommandTimeout);
+ }
+
+ lastCommandTimeout = setTimeout(() => {
+ lastCommand = null;
+ }, 5000);
+
+ const promise = $.ajax({
+ url: '/command',
+ method: 'POST',
+ data: JSON.stringify(payload)
+ });
+
+ return promise.then((data) => {
+ dispatch(addCommand(data));
+ });
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_COMMANDS]: createFetchHandler('commands', '/command'),
+
+ [EXECUTE_COMMAND]: function(getState, payload, dispatch) {
+ executeCommandHelper(payload, dispatch);
+ },
+
+ [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'),
+
+ [UPDATE_COMMAND]: function(getState, payload, dispatch) {
+ dispatch(updateItem({ section: 'commands', ...payload }));
+
+ showCommandMessage(payload, dispatch);
+ scheduleRemoveCommand(payload, dispatch);
+ },
+
+ [FINISH_COMMAND]: function(getState, payload, dispatch) {
+ const state = getState();
+ const handlers = state.commands.handlers;
+
+ Object.keys(handlers).forEach((key) => {
+ const handler = handlers[key];
+
+ if (handler.name === payload.name) {
+ dispatch(handler.handler(payload));
+ }
+ });
+
+ dispatch(updateItem({ section: 'commands', ...payload }));
+ scheduleRemoveCommand(payload, dispatch);
+ showCommandMessage(payload, dispatch);
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [ADD_COMMAND]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ newState.items = [...state.items, payload];
+
+ return newState;
+ },
+
+ [REMOVE_COMMAND]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ newState.items = [...state.items];
+
+ const index = _.findIndex(newState.items, { id: payload.id });
+
+ if (index > -1) {
+ newState.items.splice(index, 1);
+ }
+
+ return newState;
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js
new file mode 100644
index 000000000..750c3ef6f
--- /dev/null
+++ b/frontend/src/Store/Actions/customFilterActions.js
@@ -0,0 +1,55 @@
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'customFilters';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: [],
+ pendingChanges: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters';
+export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter';
+export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter';
+
+//
+// Action Creators
+
+export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS);
+export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER);
+export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'),
+
+ [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'),
+
+ [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter')
+
+});
+
+//
+// Reducers
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/deviceActions.js b/frontend/src/Store/Actions/deviceActions.js
new file mode 100644
index 000000000..089d49bf3
--- /dev/null
+++ b/frontend/src/Store/Actions/deviceActions.js
@@ -0,0 +1,83 @@
+import { createAction } from 'redux-actions';
+import requestAction from 'Utilities/requestAction';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set } from './baseActions';
+
+//
+// Variables
+
+export const section = 'devices';
+
+//
+// State
+
+export const defaultState = {
+ items: [],
+ isFetching: false,
+ isPopulated: false,
+ error: false
+};
+
+//
+// Actions Types
+
+export const FETCH_DEVICES = 'devices/fetchDevices';
+export const CLEAR_DEVICES = 'devices/clearDevices';
+
+//
+// Action Creators
+
+export const fetchDevices = createThunk(FETCH_DEVICES);
+export const clearDevices = createAction(CLEAR_DEVICES);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_DEVICES]: function(getState, payload, dispatch) {
+ const actionPayload = {
+ action: 'getDevices',
+ ...payload
+ };
+
+ dispatch(set({
+ section,
+ isFetching: true
+ }));
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data.devices || []
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_DEVICES]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/episodeActions.js b/frontend/src/Store/Actions/episodeActions.js
new file mode 100644
index 000000000..2c367d343
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeActions.js
@@ -0,0 +1,235 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import episodeEntities from 'Episode/episodeEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'episodes';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'episodeNumber',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'monitored',
+ columnLabel: 'Monitored',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'episodeNumber',
+ label: '#',
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isVisible: false
+ },
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isVisible: false
+ },
+ {
+ name: 'airDateUtc',
+ label: 'Air Date',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ {
+ name: 'audioInfo',
+ label: 'Audio Info',
+ isVisible: false
+ },
+ {
+ name: 'videoCodec',
+ label: 'Video Codec',
+ isVisible: false
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isVisible: false
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'episodes.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_EPISODES = 'episodes/fetchEpisodes';
+export const SET_EPISODES_SORT = 'episodes/setEpisodesSort';
+export const SET_EPISODES_TABLE_OPTION = 'episodes/setEpisodesTableOption';
+export const CLEAR_EPISODES = 'episodes/clearEpisodes';
+export const TOGGLE_EPISODE_MONITORED = 'episodes/toggleEpisodeMonitored';
+export const TOGGLE_EPISODES_MONITORED = 'episodes/toggleEpisodesMonitored';
+
+//
+// Action Creators
+
+export const fetchEpisodes = createThunk(FETCH_EPISODES);
+export const setEpisodesSort = createAction(SET_EPISODES_SORT);
+export const setEpisodesTableOption = createAction(SET_EPISODES_TABLE_OPTION);
+export const clearEpisodes = createAction(CLEAR_EPISODES);
+export const toggleEpisodeMonitored = createThunk(TOGGLE_EPISODE_MONITORED);
+export const toggleEpisodesMonitored = createThunk(TOGGLE_EPISODES_MONITORED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_EPISODES]: createFetchHandler(section, '/episode'),
+
+ [TOGGLE_EPISODE_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ episodeId: id,
+ episodeEntity = episodeEntities.EPISODES,
+ monitored
+ } = payload;
+
+ dispatch(updateItem({
+ id,
+ section: episodeEntity,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: `/episode/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({ monitored }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id,
+ section: episodeEntity,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section: episodeEntity,
+ isSaving: false
+ }));
+ });
+ },
+
+ [TOGGLE_EPISODES_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ episodeIds,
+ episodeEntity = episodeEntities.EPISODES,
+ monitored
+ } = payload;
+
+ const episodeSection = _.last(episodeEntity.split('.'));
+
+ dispatch(batchActions(
+ episodeIds.map((episodeId) => {
+ return updateItem({
+ id: episodeId,
+ section: episodeSection,
+ isSaving: true
+ });
+ })
+ ));
+
+ const promise = $.ajax({
+ url: '/episode/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ episodeIds, monitored }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions(
+ episodeIds.map((episodeId) => {
+ return updateItem({
+ id: episodeId,
+ section: episodeSection,
+ isSaving: false,
+ monitored
+ });
+ })
+ ));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions(
+ episodeIds.map((episodeId) => {
+ return updateItem({
+ id: episodeId,
+ section: episodeSection,
+ isSaving: false
+ });
+ })
+ ));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_EPISODES_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_EPISODES]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ },
+
+ [SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/episodeFileActions.js b/frontend/src/Store/Actions/episodeFileActions.js
new file mode 100644
index 000000000..cb5329355
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeFileActions.js
@@ -0,0 +1,210 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import episodeEntities from 'Episode/episodeEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { set, removeItem, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'episodeFiles';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_EPISODE_FILES = 'episodeFiles/fetchEpisodeFiles';
+export const DELETE_EPISODE_FILE = 'episodeFiles/deleteEpisodeFile';
+export const DELETE_EPISODE_FILES = 'episodeFiles/deleteEpisodeFiles';
+export const UPDATE_EPISODE_FILES = 'episodeFiles/updateEpisodeFiles';
+export const CLEAR_EPISODE_FILES = 'episodeFiles/clearEpisodeFiles';
+
+//
+// Action Creators
+
+export const fetchEpisodeFiles = createThunk(FETCH_EPISODE_FILES);
+export const deleteEpisodeFile = createThunk(DELETE_EPISODE_FILE);
+export const deleteEpisodeFiles = createThunk(DELETE_EPISODE_FILES);
+export const updateEpisodeFiles = createThunk(UPDATE_EPISODE_FILES);
+export const clearEpisodeFiles = createAction(CLEAR_EPISODE_FILES);
+
+//
+// Helpers
+
+const deleteEpisodeFileHelper = createRemoveItemHandler(section, '/episodeFile');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_EPISODE_FILES]: createFetchHandler(section, '/episodeFile'),
+
+ [DELETE_EPISODE_FILE]: function(getState, payload, dispatch) {
+ const {
+ id: episodeFileId,
+ episodeEntity = episodeEntities.EPISODES
+ } = payload;
+
+ const episodeSection = _.last(episodeEntity.split('.'));
+ const deletePromise = deleteEpisodeFileHelper(getState, payload, dispatch);
+
+ deletePromise.done(() => {
+ const episodes = getState().episodes.items;
+ const episodesWithRemovedFiles = _.filter(episodes, { episodeFileId });
+
+ dispatch(batchActions([
+ ...episodesWithRemovedFiles.map((episode) => {
+ return updateItem({
+ section: episodeSection,
+ ...episode,
+ episodeFileId: 0,
+ hasFile: false
+ });
+ })
+ ]));
+ });
+ },
+
+ [DELETE_EPISODE_FILES]: function(getState, payload, dispatch) {
+ const {
+ episodeFileIds
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const promise = $.ajax({
+ url: '/episodeFile/bulk',
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ episodeFileIds })
+ });
+
+ promise.done(() => {
+ const episodes = getState().episodes.items;
+ const episodesWithRemovedFiles = episodeFileIds.reduce((acc, episodeFileId) => {
+ acc.push(..._.filter(episodes, { episodeFileId }));
+
+ return acc;
+ }, []);
+
+ dispatch(batchActions([
+ ...episodeFileIds.map((id) => {
+ return removeItem({ section, id });
+ }),
+
+ ...episodesWithRemovedFiles.map((episode) => {
+ return updateItem({
+ section: 'episodes',
+ ...episode,
+ episodeFileId: 0,
+ hasFile: false
+ });
+ }),
+
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ },
+
+ [UPDATE_EPISODE_FILES]: function(getState, payload, dispatch) {
+ const {
+ episodeFileIds,
+ language,
+ quality
+ } = payload;
+
+ dispatch(set({ section, isSaving: true }));
+
+ const data = {
+ episodeFileIds
+ };
+
+ if (language) {
+ data.language = language;
+ }
+
+ if (quality) {
+ data.quality = quality;
+ }
+
+ const promise = $.ajax({
+ url: '/episodeFile/editor',
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(data)
+ });
+
+ promise.done(() => {
+ dispatch(batchActions([
+ ...episodeFileIds.map((id) => {
+ const props = {};
+
+ if (language) {
+ props.language = language;
+ }
+
+ if (quality) {
+ props.quality = quality;
+ }
+
+ return updateItem({ section, id, ...props });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_EPISODE_FILES]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/episodeHistoryActions.js b/frontend/src/Store/Actions/episodeHistoryActions.js
new file mode 100644
index 000000000..35f6ea8b7
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeHistoryActions.js
@@ -0,0 +1,112 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { sortDirections } from 'Helpers/Props';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'episodeHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_EPISODE_HISTORY = 'episodeHistory/fetchEpisodeHistory';
+export const CLEAR_EPISODE_HISTORY = 'episodeHistory/clearEpisodeHistory';
+export const EPISODE_HISTORY_MARK_AS_FAILED = 'episodeHistory/episodeHistoryMarkAsFailed';
+
+//
+// Action Creators
+
+export const fetchEpisodeHistory = createThunk(FETCH_EPISODE_HISTORY);
+export const clearEpisodeHistory = createAction(CLEAR_EPISODE_HISTORY);
+export const episodeHistoryMarkAsFailed = createThunk(EPISODE_HISTORY_MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_EPISODE_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const queryParams = {
+ pageSize: 1000,
+ page: 1,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ episodeId: payload.episodeId
+ };
+
+ const promise = $.ajax({
+ url: '/history',
+ data: queryParams
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data: data.records }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [EPISODE_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const {
+ historyId,
+ episodeId
+ } = payload;
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id: historyId
+ }
+ });
+
+ promise.done(() => {
+ dispatch(fetchEpisodeHistory({ episodeId }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_EPISODE_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
new file mode 100644
index 000000000..9eafd6704
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -0,0 +1,267 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import { updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'history';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'eventType',
+ columnLabel: 'Event Type',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'series.sortTitle',
+ label: 'Series',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episode',
+ label: 'Episode',
+ isVisible: true
+ },
+ {
+ name: 'episodeTitle',
+ label: 'Episode Title',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'downloadClient',
+ label: 'Download Client',
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isVisible: false
+ },
+ {
+ name: 'releaseGroup',
+ label: 'Release Group',
+ isVisible: false
+ },
+ {
+ name: 'details',
+ columnLabel: 'Details',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'all',
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'grabbed',
+ label: 'Grabbed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '1',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'imported',
+ label: 'Imported',
+ filters: [
+ {
+ key: 'eventType',
+ value: '3',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'failed',
+ label: 'Failed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '4',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'deleted',
+ label: 'Deleted',
+ filters: [
+ {
+ key: 'eventType',
+ value: '5',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'renamed',
+ label: 'Renamed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '6',
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+
+};
+
+export const persistState = [
+ 'history.pageSize',
+ 'history.sortKey',
+ 'history.sortDirection',
+ 'history.selectedFilterKey'
+];
+
+//
+// Actions Types
+
+export const FETCH_HISTORY = 'history/fetchHistory';
+export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage';
+export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage';
+export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage';
+export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage';
+export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage';
+export const SET_HISTORY_SORT = 'history/setHistorySort';
+export const SET_HISTORY_FILTER = 'history/setHistoryFilter';
+export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption';
+export const CLEAR_HISTORY = 'history/clearHistory';
+export const MARK_AS_FAILED = 'history/markAsFailed';
+
+//
+// Action Creators
+
+export const fetchHistory = createThunk(FETCH_HISTORY);
+export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE);
+export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE);
+export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE);
+export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE);
+export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE);
+export const setHistorySort = createThunk(SET_HISTORY_SORT);
+export const setHistoryFilter = createThunk(SET_HISTORY_FILTER);
+export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION);
+export const clearHistory = createAction(CLEAR_HISTORY);
+export const markAsFailed = createThunk(MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...createServerSideCollectionHandlers(
+ section,
+ '/history',
+ fetchHistory,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_HISTORY,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER
+ }),
+
+ [MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const id = payload.id;
+
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: true
+ }));
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id
+ }
+ });
+
+ promise.done(() => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_HISTORY]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/importSeriesActions.js b/frontend/src/Store/Actions/importSeriesActions.js
new file mode 100644
index 000000000..74d68f87e
--- /dev/null
+++ b/frontend/src/Store/Actions/importSeriesActions.js
@@ -0,0 +1,328 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import getNewSeries from 'Utilities/Series/getNewSeries';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set, removeItem, updateItem } from './baseActions';
+import { fetchRootFolders } from './rootFolderActions';
+
+//
+// Variables
+
+export const section = 'importSeries';
+let concurrentLookups = 0;
+let abortCurrentLookup = null;
+const queue = [];
+
+//
+// State
+
+export const defaultState = {
+ isLookingUpSeries: false,
+ isImporting: false,
+ isImported: false,
+ importError: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const QUEUE_LOOKUP_SERIES = 'importSeries/queueLookupSeries';
+export const START_LOOKUP_SERIES = 'importSeries/startLookupSeries';
+export const CANCEL_LOOKUP_SERIES = 'importSeries/cancelLookupSeries';
+export const LOOKUP_UNSEARCHED_SERIES = 'importSeries/lookupUnsearchedSeries';
+export const CLEAR_IMPORT_SERIES = 'importSeries/clearImportSeries';
+export const SET_IMPORT_SERIES_VALUE = 'importSeries/setImportSeriesValue';
+export const IMPORT_SERIES = 'importSeries/importSeries';
+
+//
+// Action Creators
+
+export const queueLookupSeries = createThunk(QUEUE_LOOKUP_SERIES);
+export const startLookupSeries = createThunk(START_LOOKUP_SERIES);
+export const importSeries = createThunk(IMPORT_SERIES);
+export const lookupUnsearchedSeries = createThunk(LOOKUP_UNSEARCHED_SERIES);
+export const clearImportSeries = createAction(CLEAR_IMPORT_SERIES);
+export const cancelLookupSeries = createAction(CANCEL_LOOKUP_SERIES);
+
+export const setImportSeriesValue = createAction(SET_IMPORT_SERIES_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [QUEUE_LOOKUP_SERIES]: function(getState, payload, dispatch) {
+ const {
+ name,
+ path,
+ term,
+ topOfQueue = false
+ } = payload;
+
+ const state = getState().importSeries;
+ const item = _.find(state.items, { id: name }) || {
+ id: name,
+ term,
+ path,
+ isFetching: false,
+ isPopulated: false,
+ error: null
+ };
+
+ dispatch(updateItem({
+ section,
+ ...item,
+ term,
+ isQueued: true,
+ items: []
+ }));
+
+ const itemIndex = queue.indexOf(item.id);
+
+ if (itemIndex >= 0) {
+ queue.splice(itemIndex, 1);
+ }
+
+ if (topOfQueue) {
+ queue.unshift(item.id);
+ } else {
+ queue.push(item.id);
+ }
+
+ if (term && term.length > 2) {
+ dispatch(startLookupSeries({ start: true }));
+ }
+ },
+
+ [START_LOOKUP_SERIES]: function(getState, payload, dispatch) {
+ if (concurrentLookups >= 1) {
+ return;
+ }
+
+ const state = getState().importSeries;
+
+ const {
+ isLookingUpSeries,
+ items
+ } = state;
+
+ const queueId = queue[0];
+
+ if (payload.start && !isLookingUpSeries) {
+ dispatch(set({ section, isLookingUpSeries: true }));
+ } else if (!isLookingUpSeries) {
+ return;
+ } else if (!queueId) {
+ dispatch(set({ section, isLookingUpSeries: false }));
+ return;
+ }
+
+ concurrentLookups++;
+ queue.splice(0, 1);
+
+ const queued = items.find((i) => i.id === queueId);
+
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: true
+ }));
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/series/lookup',
+ data: {
+ term: queued.term
+ }
+ });
+
+ abortCurrentLookup = abortRequest;
+
+ request.done((data) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data,
+ isQueued: false,
+ selectedSeries: queued.selectedSeries || data[0],
+ updateOnly: true
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr,
+ isQueued: false,
+ updateOnly: true
+ }));
+ });
+
+ request.always(() => {
+ concurrentLookups--;
+
+ dispatch(startLookupSeries());
+ });
+ },
+
+ [LOOKUP_UNSEARCHED_SERIES]: function(getState, payload, dispatch) {
+ const state = getState().importSeries;
+
+ if (state.isLookingUpSeries) {
+ return;
+ }
+
+ state.items.forEach((item) => {
+ const id = item.id;
+
+ if (
+ !item.isPopulated &&
+ !queue.includes(id)
+ ) {
+ queue.push(item.id);
+ }
+ });
+
+ if (queue.length) {
+ dispatch(startLookupSeries({ start: true }));
+ }
+ },
+
+ [IMPORT_SERIES]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isImporting: true }));
+
+ const ids = payload.ids;
+ const items = getState().importSeries.items;
+ const addedIds = [];
+
+ const allNewSeries = ids.reduce((acc, id) => {
+ const item = _.find(items, { id });
+ const selectedSeries = item.selectedSeries;
+
+ // Make sure we have a selected series and
+ // the same series hasn't been added yet.
+ if (selectedSeries && !_.some(acc, { tvdbId: selectedSeries.tvdbId })) {
+ const newSeries = getNewSeries(_.cloneDeep(selectedSeries), item);
+ newSeries.path = item.path;
+
+ addedIds.push(id);
+ acc.push(newSeries);
+ }
+
+ return acc;
+ }, []);
+
+ const promise = $.ajax({
+ url: '/series/import',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(allNewSeries)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isImporting: false,
+ isImported: true
+ }),
+
+ ...data.map((series) => updateItem({ section: 'series', ...series })),
+
+ ...addedIds.map((id) => removeItem({ section, id }))
+ ]));
+
+ dispatch(fetchRootFolders());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions(
+ set({
+ section,
+ isImporting: false,
+ isImported: true
+ }),
+
+ addedIds.map((id) => updateItem({
+ section,
+ id,
+ importError: xhr
+ }))
+ ));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CANCEL_LOOKUP_SERIES]: function(state) {
+ queue.splice(0, queue.length);
+
+ const items = state.items.map((item) => {
+ if (item.isQueued) {
+ return {
+ ...item,
+ isQueued: false
+ };
+ }
+
+ return item;
+ });
+
+ return Object.assign({}, state, {
+ isLookingUpSeries: false,
+ items
+ });
+ },
+
+ [CLEAR_IMPORT_SERIES]: function(state) {
+ if (abortCurrentLookup) {
+ abortCurrentLookup();
+
+ abortCurrentLookup = null;
+ }
+
+ queue.splice(0, queue.length);
+
+ return Object.assign({}, state, defaultState);
+ },
+
+ [SET_IMPORT_SERIES_VALUE]: function(state, { payload }) {
+ const newState = getSectionState(state, section);
+ const items = newState.items;
+ const index = _.findIndex(items, { id: payload.id });
+
+ newState.items = [...items];
+
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...payload });
+ } else {
+ newState.items.push({ ...payload });
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js
new file mode 100644
index 000000000..015be478f
--- /dev/null
+++ b/frontend/src/Store/Actions/index.js
@@ -0,0 +1,61 @@
+import * as addSeries from './addSeriesActions';
+import * as app from './appActions';
+import * as blacklist from './blacklistActions';
+import * as calendar from './calendarActions';
+import * as captcha from './captchaActions';
+import * as customFilters from './customFilterActions';
+import * as devices from './deviceActions';
+import * as commands from './commandActions';
+import * as episodes from './episodeActions';
+import * as episodeFiles from './episodeFileActions';
+import * as episodeHistory from './episodeHistoryActions';
+import * as history from './historyActions';
+import * as importSeries from './importSeriesActions';
+import * as interactiveImportActions from './interactiveImportActions';
+import * as oAuth from './oAuthActions';
+import * as organizePreview from './organizePreviewActions';
+import * as paths from './pathActions';
+import * as queue from './queueActions';
+import * as releases from './releaseActions';
+import * as rootFolders from './rootFolderActions';
+import * as seasonPass from './seasonPassActions';
+import * as series from './seriesActions';
+import * as seriesEditor from './seriesEditorActions';
+import * as seriesHistory from './seriesHistoryActions';
+import * as seriesIndex from './seriesIndexActions';
+import * as settings from './settingsActions';
+import * as system from './systemActions';
+import * as tags from './tagActions';
+import * as wanted from './wantedActions';
+
+export default [
+ addSeries,
+ app,
+ blacklist,
+ calendar,
+ captcha,
+ commands,
+ customFilters,
+ devices,
+ episodes,
+ episodeFiles,
+ episodeHistory,
+ history,
+ importSeries,
+ interactiveImportActions,
+ oAuth,
+ organizePreview,
+ paths,
+ queue,
+ releases,
+ rootFolders,
+ seasonPass,
+ series,
+ seriesEditor,
+ seriesHistory,
+ seriesIndex,
+ settings,
+ system,
+ tags,
+ wanted
+];
diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js
new file mode 100644
index 000000000..538790756
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActions.js
@@ -0,0 +1,204 @@
+import $ from 'jquery';
+import moment from 'moment';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { sortDirections } from 'Helpers/Props';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'interactiveImport';
+
+const episodesSection = `${section}.episodes`;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'quality',
+ sortDirection: sortDirections.DESCENDING,
+ recentFolders: [],
+ importMode: 'move',
+ sortPredicates: {
+ relativePath: function(item, direction) {
+ const relativePath = item.relativePath;
+
+ return relativePath.toLowerCase();
+ },
+
+ series: function(item, direction) {
+ const series = item.series;
+
+ return series ? series.sortTitle : '';
+ },
+
+ quality: function(item, direction) {
+ return item.quality ? item.quality.qualityWeight : 0;
+ }
+ },
+
+ episodes: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'episodeNumber',
+ sortDirection: sortDirections.DESCENDING,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'interactiveImport.recentFolders',
+ 'interactiveImport.importMode'
+];
+
+//
+// Actions Types
+
+export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems';
+export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort';
+export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem';
+export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport';
+export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder';
+export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder';
+export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode';
+
+export const FETCH_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/fetchInteractiveImportEpisodes';
+export const SET_INTERACTIVE_IMPORT_EPISODES_SORT = 'interactiveImport/setInteractiveImportEpisodesSort';
+export const CLEAR_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/clearInteractiveImportEpisodes';
+
+//
+// Action Creators
+
+export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS);
+export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT);
+export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM);
+export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT);
+export const addRecentFolder = createAction(ADD_RECENT_FOLDER);
+export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER);
+export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE);
+
+export const fetchInteractiveImportEpisodes = createThunk(FETCH_INTERACTIVE_IMPORT_EPISODES);
+export const setInteractiveImportEpisodesSort = createAction(SET_INTERACTIVE_IMPORT_EPISODES_SORT);
+export const clearInteractiveImportEpisodes = createAction(CLEAR_INTERACTIVE_IMPORT_EPISODES);
+
+//
+// Action Handlers
+export const actionHandlers = handleThunks({
+ [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) {
+ if (!payload.downloadId && !payload.folder) {
+ dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } }));
+ return;
+ }
+
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = $.ajax({
+ url: '/manualimport',
+ data: payload
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [FETCH_INTERACTIVE_IMPORT_EPISODES]: createFetchHandler('interactiveImport.episodes', '/episode')
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => {
+ const id = payload.id;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+ const index = items.findIndex((item) => item.id === id);
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [ADD_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolder = { folder, lastUsed: moment().toISOString() };
+ const recentFolders = [...state.recentFolders];
+ const index = recentFolders.findIndex((r) => r.folder === folder);
+
+ if (index > -1) {
+ recentFolders.splice(index, 1, recentFolder);
+ } else {
+ recentFolders.push(recentFolder);
+ }
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [REMOVE_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolders = [...state.recentFolders];
+ const index = recentFolders.findIndex((r) => r.folder === folder);
+
+ recentFolders.splice(index, 1);
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [CLEAR_INTERACTIVE_IMPORT]: function(state) {
+ const newState = {
+ ...defaultState,
+ recentFolders: state.recentFolders,
+ importMode: state.importMode
+ };
+
+ return newState;
+ },
+
+ [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section),
+
+ [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) {
+ return Object.assign({}, state, { importMode: payload.importMode });
+ },
+
+ [SET_INTERACTIVE_IMPORT_EPISODES_SORT]: createSetClientSideCollectionSortReducer(episodesSection),
+
+ [CLEAR_INTERACTIVE_IMPORT_EPISODES]: (state) => {
+ return updateSectionState(state, episodesSection, {
+ ...defaultState.episodes
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js
new file mode 100644
index 000000000..e1d5cd124
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActions.js
@@ -0,0 +1,205 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import requestAction from 'Utilities/requestAction';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { set } from 'Store/Actions/baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'oAuth';
+const callbackUrl = `${window.location.origin}${window.Sonarr.urlBase}/oauth.html`;
+
+//
+// State
+
+export const defaultState = {
+ authorizing: false,
+ result: null,
+ error: null
+};
+
+//
+// Actions Types
+
+export const START_OAUTH = 'oAuth/startOAuth';
+export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue';
+export const RESET_OAUTH = 'oAuth/resetOAuth';
+
+//
+// Action Creators
+
+export const startOAuth = createThunk(START_OAUTH);
+export const setOAuthValue = createAction(SET_OAUTH_VALUE);
+export const resetOAuth = createAction(RESET_OAUTH);
+
+//
+// Helpers
+
+function showOAuthWindow(url, payload) {
+ const deferred = $.Deferred();
+ const selfWindow = window;
+
+ const newWindow = window.open(url);
+
+ if (
+ !newWindow ||
+ newWindow.closed ||
+ typeof newWindow.closed == 'undefined'
+ ) {
+
+ // A fake validation error to mimic a 400 response from the API.
+ const error = {
+ status: 400,
+ responseJSON: [
+ {
+ propertyName: payload.name,
+ errorMessage: 'Pop-ups are being blocked by your browser'
+ }
+ ]
+ };
+
+ return deferred.reject(error).promise();
+ }
+
+ selfWindow.onCompleteOauth = function(query, onComplete) {
+ delete selfWindow.onCompleteOauth;
+
+ const queryParams = {};
+ const splitQuery = query.substring(1).split('&');
+
+ splitQuery.forEach((param) => {
+ if (param) {
+ const paramSplit = param.split('=');
+
+ queryParams[paramSplit[0]] = paramSplit[1];
+ }
+ });
+
+ onComplete();
+ deferred.resolve(queryParams);
+ };
+
+ return deferred.promise();
+}
+
+function executeIntermediateRequest(payload, ajaxOptions) {
+ return $.ajax(ajaxOptions).then((data) => {
+ return requestAction({
+ action: 'continueOAuth',
+ queryParams: {
+ ...data,
+ callbackUrl
+ },
+ ...payload
+ });
+ });
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [START_OAUTH]: function(getState, payload, dispatch) {
+ const {
+ name,
+ section: actionSection,
+ ...otherPayload
+ } = payload;
+
+ const actionPayload = {
+ action: 'startOAuth',
+ queryParams: { callbackUrl },
+ ...otherPayload
+ };
+
+ dispatch(setOAuthValue({
+ authorizing: true
+ }));
+
+ let startResponse = {};
+
+ const promise = requestAction(actionPayload)
+ .then((response) => {
+ startResponse = response;
+
+ if (response.oauthUrl) {
+ return showOAuthWindow(response.oauthUrl, payload);
+ }
+
+ return executeIntermediateRequest(otherPayload, response).then((intermediateResponse) => {
+ startResponse = intermediateResponse;
+
+ return showOAuthWindow(intermediateResponse.oauthUrl, payload);
+ });
+ })
+ .then((queryParams) => {
+ return requestAction({
+ action: 'getOAuthToken',
+ queryParams: {
+ ...startResponse,
+ ...queryParams
+ },
+ ...otherPayload
+ });
+ })
+ .then((response) => {
+ dispatch(setOAuthValue({
+ authorizing: false,
+ result: response,
+ error: null
+ }));
+ });
+
+ promise.done(() => {
+ // Clear any previously set save error.
+ dispatch(set({
+ section: actionSection,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ const actions = [
+ setOAuthValue({
+ authorizing: false,
+ result: null,
+ error: xhr
+ })
+ ];
+
+ if (xhr.status === 400) {
+ // Set a save error so the UI can display validation errors to the user.
+ actions.splice(0, 0, set({
+ section: actionSection,
+ saveError: xhr
+ }));
+ }
+
+ dispatch(batchActions(actions));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_OAUTH_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [RESET_OAUTH]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js
new file mode 100644
index 000000000..78f943f32
--- /dev/null
+++ b/frontend/src/Store/Actions/organizePreviewActions.js
@@ -0,0 +1,51 @@
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'organizePreview';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ORGANIZE_PREVIEW = 'organizePreview/fetchOrganizePreview';
+export const CLEAR_ORGANIZE_PREVIEW = 'organizePreview/clearOrganizePreview';
+
+//
+// Action Creators
+
+export const fetchOrganizePreview = createThunk(FETCH_ORGANIZE_PREVIEW);
+export const clearOrganizePreview = createAction(CLEAR_ORGANIZE_PREVIEW);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename')
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_ORGANIZE_PREVIEW]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js
new file mode 100644
index 000000000..129a9cb4d
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActions.js
@@ -0,0 +1,110 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set } from './baseActions';
+
+//
+// Variables
+
+export const section = 'paths';
+
+//
+// State
+
+export const defaultState = {
+ currentPath: '',
+ isPopulated: false,
+ isFetching: false,
+ error: null,
+ directories: [],
+ files: [],
+ parent: null
+};
+
+//
+// Actions Types
+
+export const FETCH_PATHS = 'paths/fetchPaths';
+export const UPDATE_PATHS = 'paths/updatePaths';
+export const CLEAR_PATHS = 'paths/clearPaths';
+
+//
+// Action Creators
+
+export const fetchPaths = createThunk(FETCH_PATHS);
+export const updatePaths = createAction(UPDATE_PATHS);
+export const clearPaths = createAction(CLEAR_PATHS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_PATHS]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ path,
+ allowFoldersWithoutTrailingSlashes = false
+ } = payload;
+
+ const promise = $.ajax({
+ url: '/filesystem',
+ data: {
+ path,
+ allowFoldersWithoutTrailingSlashes
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(updatePaths({ path, ...data }));
+
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [UPDATE_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.currentPath = payload.path;
+ newState.directories = payload.directories;
+ newState.files = payload.files;
+ newState.parent = payload.parent;
+
+ return newState;
+ },
+
+ [CLEAR_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.path = '';
+ newState.directories = [];
+ newState.files = [];
+ newState.parent = '';
+
+ return newState;
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js
new file mode 100644
index 000000000..bb6b3c1f2
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActions.js
@@ -0,0 +1,438 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import { set, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'queue';
+const status = `${section}.status`;
+const details = `${section}.details`;
+const paged = `${section}.paged`;
+
+//
+// State
+
+export const defaultState = {
+ options: {
+ includeUnknownSeriesItems: false
+ },
+
+ status: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ details: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ params: {}
+ },
+
+ paged: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'timeleft',
+ sortDirection: sortDirections.ASCENDING,
+ error: null,
+ items: [],
+ isGrabbing: false,
+ isRemoving: false,
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'series.sortTitle',
+ label: 'Series',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episode',
+ label: 'Episode',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episode.title',
+ label: 'Episode Title',
+ isVisible: true
+ },
+ {
+ name: 'episode.airDateUtc',
+ label: 'Episode Air Date',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'protocol',
+ label: 'Protocol',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'downloadClient',
+ label: 'Download Client',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'estimatedCompletionTime',
+ label: 'Timeleft',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ label: 'Progress',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'queue.options',
+ 'queue.paged.pageSize',
+ 'queue.paged.sortKey',
+ 'queue.paged.sortDirection',
+ 'queue.paged.columns'
+];
+
+//
+// Helpers
+
+function fetchDataAugmenter(getState, payload, data) {
+ data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems;
+}
+
+//
+// Actions Types
+
+export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
+
+export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
+export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
+
+export const FETCH_QUEUE = 'queue/fetchQueue';
+export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
+export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
+export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
+export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
+export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
+export const SET_QUEUE_SORT = 'queue/setQueueSort';
+export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
+export const SET_QUEUE_OPTION = 'queue/setQueueOption';
+export const CLEAR_QUEUE = 'queue/clearQueue';
+
+export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
+export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
+export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
+export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
+
+//
+// Action Creators
+
+export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
+
+export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
+export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
+
+export const fetchQueue = createThunk(FETCH_QUEUE);
+export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
+export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
+export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
+export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
+export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
+export const setQueueSort = createThunk(SET_QUEUE_SORT);
+export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
+export const setQueueOption = createAction(SET_QUEUE_OPTION);
+export const clearQueue = createAction(CLEAR_QUEUE);
+
+export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
+export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
+export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
+export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
+
+//
+// Helpers
+
+const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
+
+ [FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
+ let params = payload;
+
+ // If the payload params are empty try to get params from state.
+
+ if (params && !_.isEmpty(params)) {
+ dispatch(set({ section: details, params }));
+ } else {
+ params = getState().queue.details.params;
+ }
+
+ // Ensure there are params before trying to fetch the queue
+ // so we don't make a bad request to the server.
+
+ if (params && !_.isEmpty(params)) {
+ fetchQueueDetailsHelper(getState, params, dispatch);
+ }
+ },
+
+ ...createServerSideCollectionHandlers(
+ paged,
+ '/queue',
+ fetchQueue,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT
+ },
+ fetchDataAugmenter
+ ),
+
+ [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
+ const id = payload.id;
+
+ dispatch(updateItem({ section: paged, id, isGrabbing: true }));
+
+ const promise = $.ajax({
+ url: `/queue/grab/${id}`,
+ method: 'POST'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ fetchQueue(),
+
+ set({
+ section: paged,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: xhr
+ }));
+ });
+ },
+
+ [GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
+ const ids = payload.ids;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: true
+ });
+ }),
+
+ set({
+ section: paged,
+ isGrabbing: true
+ })
+ ]));
+
+ const promise = $.ajax({
+ url: '/queue/grab/bulk',
+ method: 'POST',
+ dataType: 'json',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ fetchQueue(),
+
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({
+ section: paged,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({ section: paged, isGrabbing: false })
+ ]));
+ });
+ },
+
+ [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
+ const {
+ id,
+ blacklist
+ } = payload;
+
+ dispatch(updateItem({ section: paged, id, isRemoving: true }));
+
+ const promise = $.ajax({
+ url: `/queue/${id}?blacklist=${blacklist}`,
+ method: 'DELETE'
+ });
+
+ promise.done((data) => {
+ dispatch(fetchQueue());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({ section: paged, id, isRemoving: false }));
+ });
+ },
+
+ [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
+ const {
+ ids,
+ blacklist
+ } = payload;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isRemoving: true
+ });
+ }),
+
+ set({ section: paged, isRemoving: true })
+ ]));
+
+ const promise = $.ajax({
+ url: `/queue/bulk?blacklist=${blacklist}`,
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ ids })
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({ section: paged, isRemoving: false }),
+ fetchQueue()
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isRemoving: false
+ });
+ }),
+
+ set({ section: paged, isRemoving: false })
+ ]));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
+
+ [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
+
+ [SET_QUEUE_OPTION]: function(state, { payload }) {
+ const queueOptions = state.options;
+
+ return {
+ ...state,
+ options: {
+ ...queueOptions,
+ ...payload
+ }
+ };
+ },
+
+ [CLEAR_QUEUE]: createClearReducer(paged, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/reducers.js b/frontend/src/Store/Actions/reducers.js
new file mode 100644
index 000000000..0254cd226
--- /dev/null
+++ b/frontend/src/Store/Actions/reducers.js
@@ -0,0 +1,20 @@
+import { combineReducers } from 'redux';
+import { enableBatching } from 'redux-batched-actions';
+import { routerReducer } from 'react-router-redux';
+import actions from 'Store/Actions';
+
+const defaultState = {};
+
+const reducers = {
+ routing: routerReducer
+};
+
+actions.forEach((action) => {
+ const section = action.section;
+
+ defaultState[section] = action.defaultState;
+ reducers[section] = action.reducers;
+});
+
+export { defaultState };
+export default enableBatching(combineReducers(reducers));
diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
new file mode 100644
index 000000000..1ea4fa6b3
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -0,0 +1,280 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'releases';
+export const episodeSection = 'releases.episode';
+export const seasonSection = 'releases.season';
+
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'releaseWeight',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ peers: function(item, direction) {
+ const seeders = item.seeders || 0;
+ const leechers = item.leechers || 0;
+
+ return seeders * 1000000 + leechers;
+ },
+
+ rejections: function(item, direction) {
+ const rejections = item.rejections;
+ const releaseWeight = item.releaseWeight;
+
+ if (rejections.length !== 0) {
+ return releaseWeight + 1000000;
+ }
+
+ return releaseWeight;
+ }
+ },
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'season-pack',
+ label: 'Season Pack',
+ filters: [
+ {
+ key: 'fullSeason',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'not-season-pack',
+ label: 'Not Season Pack',
+ filters: [
+ {
+ key: 'fullSeason',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ],
+
+ filterPredicates: {
+ quality: function(item, value, type) {
+ const qualityId = item.quality.quality.id;
+
+ if (type === filterTypes.EQUAL) {
+ return qualityId === value;
+ }
+
+ if (type === filterTypes.NOT_EQUAL) {
+ return qualityId !== value;
+ }
+
+ // Default to false
+ return false;
+ }
+ },
+
+ filterBuilderProps: [
+ {
+ name: 'title',
+ label: 'Title',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'age',
+ label: 'Age',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'protocol',
+ label: 'Protocol',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.PROTOCOL
+ },
+ {
+ name: 'indexerId',
+ label: 'Indexer',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.INDEXER
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'seeders',
+ label: 'Seeders',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'peers',
+ label: 'Peers',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY
+ },
+ {
+ name: 'rejections',
+ label: 'Rejections',
+ type: filterBuilderTypes.NUMBER
+ }
+ ],
+
+ episode: {
+ selectedFilterKey: 'all'
+ },
+
+ season: {
+ selectedFilterKey: 'season-pack'
+ }
+};
+
+export const persistState = [
+ 'releases.selectedFilterKey',
+ 'releases.episode.customFilters',
+ 'releases.season.customFilters'
+];
+
+//
+// Actions Types
+
+export const FETCH_RELEASES = 'releases/fetchReleases';
+export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases';
+export const SET_RELEASES_SORT = 'releases/setReleasesSort';
+export const CLEAR_RELEASES = 'releases/clearReleases';
+export const GRAB_RELEASE = 'releases/grabRelease';
+export const UPDATE_RELEASE = 'releases/updateRelease';
+export const SET_EPISODE_RELEASES_FILTER = 'releases/setEpisodeReleasesFilter';
+export const SET_SEASON_RELEASES_FILTER = 'releases/setSeasonReleasesFilter';
+
+//
+// Action Creators
+
+export const fetchReleases = createThunk(FETCH_RELEASES);
+export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES);
+export const setReleasesSort = createAction(SET_RELEASES_SORT);
+export const clearReleases = createAction(CLEAR_RELEASES);
+export const grabRelease = createThunk(GRAB_RELEASE);
+export const updateRelease = createAction(UPDATE_RELEASE);
+export const setEpisodeReleasesFilter = createAction(SET_EPISODE_RELEASES_FILTER);
+export const setSeasonReleasesFilter = createAction(SET_SEASON_RELEASES_FILTER);
+
+//
+// Helpers
+
+const fetchReleasesHelper = createFetchHandler(section, '/release');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_RELEASES]: function(getState, payload, dispatch) {
+ const abortRequest = fetchReleasesHelper(getState, payload, dispatch);
+
+ abortCurrentRequest = abortRequest;
+ },
+
+ [CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) {
+ if (abortCurrentRequest) {
+ abortCurrentRequest = abortCurrentRequest();
+ }
+ },
+
+ [GRAB_RELEASE]: function(getState, payload, dispatch) {
+ const guid = payload.guid;
+
+ dispatch(updateRelease({ guid, isGrabbing: true }));
+
+ const promise = $.ajax({
+ url: '/release',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: true,
+ grabError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue';
+
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: false,
+ grabError
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RELEASES]: (state) => {
+ const {
+ episode,
+ season,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ },
+
+ [UPDATE_RELEASE]: (state, { payload }) => {
+ const guid = payload.guid;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+
+ // Return early if there aren't any items (the user closed the modal)
+ if (!items.length) {
+ return;
+ }
+
+ const index = items.findIndex((item) => item.guid === guid);
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_EPISODE_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(episodeSection),
+ [SET_SEASON_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(seasonSection)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js
new file mode 100644
index 000000000..8180cdc7d
--- /dev/null
+++ b/frontend/src/Store/Actions/rootFolderActions.js
@@ -0,0 +1,97 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { set, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'rootFolders';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders';
+export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder';
+export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder';
+
+//
+// Action Creators
+
+export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS);
+export const addRootFolder = createThunk(ADD_ROOT_FOLDER);
+export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'),
+
+ [DELETE_ROOT_FOLDER]: createRemoveItemHandler(
+ 'rootFolders',
+ '/rootFolder',
+ (state) => state.rootFolders
+ ),
+
+ [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) {
+ const path = payload.path;
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/rootFolder',
+ method: 'POST',
+ data: JSON.stringify({ path }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({
+ section,
+ ...data
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/seasonPassActions.js b/frontend/src/Store/Actions/seasonPassActions.js
new file mode 100644
index 000000000..d2040136d
--- /dev/null
+++ b/frontend/src/Store/Actions/seasonPassActions.js
@@ -0,0 +1,164 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set } from './baseActions';
+import { fetchSeries, filters, filterPredicates } from './seriesActions';
+
+//
+// Variables
+
+export const section = 'seasonPass';
+
+//
+// State
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ selectedFilterKey: 'all',
+ filters,
+ filterPredicates,
+
+ filterBuilderProps: [
+ {
+ name: 'monitored',
+ label: 'Monitored',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.SERIES_STATUS
+ },
+ {
+ name: 'seriesType',
+ label: 'Series Type',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'languageProfileId',
+ label: 'Language Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.LANGUAGE_PROFILE
+ },
+ {
+ name: 'rootFolderPath',
+ label: 'Root Folder Path',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ]
+};
+
+export const persistState = [
+ 'seasonPass.sortKey',
+ 'seasonPass.sortDirection',
+ 'seasonPass.selectedFilterKey',
+ 'seasonPass.customFilters'
+];
+
+//
+// Actions Types
+
+export const SET_SEASON_PASS_SORT = 'seasonPass/setSeasonPassSort';
+export const SET_SEASON_PASS_FILTER = 'seasonPass/setSeasonPassFilter';
+export const SAVE_SEASON_PASS = 'seasonPass/saveSeasonPass';
+
+//
+// Action Creators
+
+export const setSeasonPassSort = createAction(SET_SEASON_PASS_SORT);
+export const setSeasonPassFilter = createAction(SET_SEASON_PASS_FILTER);
+export const saveSeasonPass = createThunk(SAVE_SEASON_PASS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [SAVE_SEASON_PASS]: function(getState, payload, dispatch) {
+ const {
+ seriesIds,
+ monitored,
+ monitor
+ } = payload;
+
+ const series = [];
+
+ seriesIds.forEach((id) => {
+ const seriesToUpdate = { id };
+
+ if (payload.hasOwnProperty('monitored')) {
+ seriesToUpdate.monitored = monitored;
+ }
+
+ series.push(seriesToUpdate);
+ });
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/seasonPass',
+ method: 'POST',
+ data: JSON.stringify({
+ series,
+ monitoringOptions: { monitor }
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(fetchSeries());
+
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_SEASON_PASS_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_SEASON_PASS_FILTER]: createSetClientSideCollectionFilterReducer(section)
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/seriesActions.js b/frontend/src/Store/Actions/seriesActions.js
new file mode 100644
index 000000000..b3e28cf44
--- /dev/null
+++ b/frontend/src/Store/Actions/seriesActions.js
@@ -0,0 +1,379 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
+import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { updateItem } from './baseActions';
+
+//
+// Local
+
+const MONITOR_TIMEOUT = 1000;
+const seasonsToUpdate = {};
+let seasonMonitorToggleTimeout = null;
+
+//
+// Variables
+
+export const section = 'series';
+
+export const filters = [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'monitored',
+ label: 'Monitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: 'Unmonitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'continuing',
+ label: 'Continuing Only',
+ filters: [
+ {
+ key: 'status',
+ value: 'continuing',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'ended',
+ label: 'Ended Only',
+ filters: [
+ {
+ key: 'status',
+ value: 'ended',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'missing',
+ label: 'Missing Episodes',
+ filters: [
+ {
+ key: 'missing',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+];
+
+export const filterPredicates = {
+ missing: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.episodeCount - statistics.episodeFileCount > 0;
+ },
+
+ nextAiring: function(item, filterValue, type) {
+ return dateFilterPredicate(item.nextAiring, filterValue, type);
+ },
+
+ previousAiring: function(item, filterValue, type) {
+ return dateFilterPredicate(item.previousAiring, filterValue, type);
+ },
+
+ added: function(item, filterValue, type) {
+ return dateFilterPredicate(item.added, filterValue, type);
+ },
+
+ ratings: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+
+ return predicate(item.ratings.value * 10, filterValue);
+ },
+
+ seasonCount: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+ const seasonCount = item.statistics ? item.statistics.seasonCount : 0;
+
+ return predicate(seasonCount, filterValue);
+ },
+
+ sizeOnDisk: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+ const sizeOnDisk = item.statistics ? item.statistics.sizeOnDisk : 0;
+
+ return predicate(sizeOnDisk, filterValue);
+ }
+};
+
+export const sortPredicates = {
+ status: function(item) {
+ let result = 0;
+
+ if (item.monitored) {
+ result += 2;
+ }
+
+ if (item.status === 'continuing') {
+ result++;
+ }
+
+ return result;
+ }
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ pendingChanges: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_SERIES = 'series/fetchSeries';
+export const SET_SERIES_VALUE = 'series/setSeriesValue';
+export const SAVE_SERIES = 'series/saveSeries';
+export const DELETE_SERIES = 'series/deleteSeries';
+
+export const TOGGLE_SERIES_MONITORED = 'series/toggleSeriesMonitored';
+export const TOGGLE_SEASON_MONITORED = 'series/toggleSeasonMonitored';
+
+//
+// Action Creators
+
+export const fetchSeries = createThunk(FETCH_SERIES);
+export const saveSeries = createThunk(SAVE_SERIES, (payload) => {
+ const newPayload = {
+ ...payload
+ };
+
+ if (payload.moveFiles) {
+ newPayload.queryParams = {
+ moveFiles: true
+ };
+ }
+
+ delete newPayload.moveFiles;
+
+ return newPayload;
+});
+
+export const deleteSeries = createThunk(DELETE_SERIES, (payload) => {
+ return {
+ ...payload,
+ queryParams: {
+ deleteFiles: payload.deleteFiles
+ }
+ };
+});
+
+export const toggleSeriesMonitored = createThunk(TOGGLE_SERIES_MONITORED);
+export const toggleSeasonMonitored = createThunk(TOGGLE_SEASON_MONITORED);
+
+export const setSeriesValue = createAction(SET_SERIES_VALUE, (payload) => {
+ return {
+ section: 'series',
+ ...payload
+ };
+});
+
+//
+// Helpers
+
+function getSaveAjaxOptions({ ajaxOptions, payload }) {
+ if (payload.moveFolder) {
+ ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`;
+ }
+
+ return ajaxOptions;
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_SERIES]: createFetchHandler(section, '/series'),
+ [SAVE_SERIES]: createSaveProviderHandler(section, '/series', { getAjaxOptions: getSaveAjaxOptions }),
+ [DELETE_SERIES]: createRemoveItemHandler(section, '/series'),
+
+ [TOGGLE_SERIES_MONITORED]: (getState, payload, dispatch) => {
+ const {
+ seriesId: id,
+ monitored
+ } = payload;
+
+ const series = _.find(getState().series.items, { id });
+
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: `/series/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...series,
+ monitored
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false
+ }));
+ });
+ },
+
+ [TOGGLE_SEASON_MONITORED]: function(getState, payload, dispatch) {
+ if (seasonMonitorToggleTimeout) {
+ seasonMonitorToggleTimeout = clearTimeout(seasonMonitorToggleTimeout);
+ }
+
+ const {
+ seriesId: id,
+ seasonNumber,
+ monitored
+ } = payload;
+
+ const series = getState().series.items.find((s) => s.id === id);
+ const seasons = _.cloneDeep(series.seasons);
+ const season = seasons.find((s) => s.seasonNumber === seasonNumber);
+
+ season.isSaving = true;
+
+ dispatch(updateItem({
+ id,
+ section,
+ seasons
+ }));
+
+ seasonsToUpdate[seasonNumber] = monitored;
+ season.monitored = monitored;
+
+ seasonMonitorToggleTimeout = setTimeout(() => {
+ $.ajax({
+ url: `/series/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...series,
+ seasons
+ }),
+ dataType: 'json'
+ }).then(
+ (data) => {
+ const changedSeasons = [];
+
+ data.seasons.forEach((s) => {
+ if (seasonsToUpdate.hasOwnProperty(s.seasonNumber)) {
+ if (s.monitored === seasonsToUpdate[s.seasonNumber]) {
+ changedSeasons.push(s);
+ } else {
+ s.isSaving = true;
+ }
+ }
+ });
+
+ const episodesToUpdate = getState().episodes.items.reduce((acc, episode) => {
+ if (episode.seriesId !== data.id) {
+ return acc;
+ }
+
+ const changedSeason = changedSeasons.find((s) => s.seasonNumber === episode.seasonNumber);
+
+ if (!changedSeason) {
+ return acc;
+ }
+
+ acc.push(updateItem({
+ id: episode.id,
+ section: 'episodes',
+ monitored: changedSeason.monitored
+ }));
+
+ return acc;
+ }, []);
+
+ dispatch(batchActions([
+ updateItem({
+ id,
+ section,
+ ...data
+ }),
+
+ ...episodesToUpdate
+ ]));
+
+ changedSeasons.forEach((s) => {
+ delete seasonsToUpdate[s.seasonNumber];
+ });
+ },
+ (xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ seasons: series.seasons
+ }));
+
+ Object.keys(seasonsToUpdate).forEach((s) => {
+ delete seasonsToUpdate[s];
+ });
+ });
+ }, MONITOR_TIMEOUT);
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_SERIES_VALUE]: createSetSettingValueReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/seriesEditorActions.js b/frontend/src/Store/Actions/seriesEditorActions.js
new file mode 100644
index 000000000..cbfd78094
--- /dev/null
+++ b/frontend/src/Store/Actions/seriesEditorActions.js
@@ -0,0 +1,190 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set, updateItem } from './baseActions';
+import { filters, filterPredicates, sortPredicates } from './seriesActions';
+
+//
+// Variables
+
+export const section = 'seriesEditor';
+
+//
+// State
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ selectedFilterKey: 'all',
+ filters,
+ filterPredicates,
+
+ filterBuilderProps: [
+ {
+ name: 'monitored',
+ label: 'Monitored',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.SERIES_STATUS
+ },
+ {
+ name: 'seriesType',
+ label: 'Series Type',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'languageProfileId',
+ label: 'Language Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.LANGUAGE_PROFILE
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'rootFolderPath',
+ label: 'Root Folder Path',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ],
+
+ sortPredicates
+};
+
+export const persistState = [
+ 'seriesEditor.sortKey',
+ 'seriesEditor.sortDirection',
+ 'seriesEditor.selectedFilterKey',
+ 'seriesEditor.customFilters'
+];
+
+//
+// Actions Types
+
+export const SET_SERIES_EDITOR_SORT = 'seriesEditor/setSeriesEditorSort';
+export const SET_SERIES_EDITOR_FILTER = 'seriesEditor/setSeriesEditorFilter';
+export const SAVE_SERIES_EDITOR = 'seriesEditor/saveSeriesEditor';
+export const BULK_DELETE_SERIES = 'seriesEditor/bulkDeleteSeries';
+//
+// Action Creators
+
+export const setSeriesEditorSort = createAction(SET_SERIES_EDITOR_SORT);
+export const setSeriesEditorFilter = createAction(SET_SERIES_EDITOR_FILTER);
+export const saveSeriesEditor = createThunk(SAVE_SERIES_EDITOR);
+export const bulkDeleteSeries = createThunk(BULK_DELETE_SERIES);
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [SAVE_SERIES_EDITOR]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/series/editor',
+ method: 'PUT',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ ...data.map((series) => {
+ return updateItem({
+ id: series.id,
+ section: 'series',
+ ...series
+ });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ },
+
+ [BULK_DELETE_SERIES]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isDeleting: true
+ }));
+
+ const promise = $.ajax({
+ url: '/series/editor',
+ method: 'DELETE',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ // SignaR will take care of removing the series from the collection
+
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_SERIES_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_SERIES_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/seriesHistoryActions.js b/frontend/src/Store/Actions/seriesHistoryActions.js
new file mode 100644
index 000000000..348853441
--- /dev/null
+++ b/frontend/src/Store/Actions/seriesHistoryActions.js
@@ -0,0 +1,104 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'seriesHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_SERIES_HISTORY = 'seriesHistory/fetchSeriesHistory';
+export const CLEAR_SERIES_HISTORY = 'seriesHistory/clearSeriesHistory';
+export const SERIES_HISTORY_MARK_AS_FAILED = 'seriesHistory/seriesHistoryMarkAsFailed';
+
+//
+// Action Creators
+
+export const fetchSeriesHistory = createThunk(FETCH_SERIES_HISTORY);
+export const clearSeriesHistory = createAction(CLEAR_SERIES_HISTORY);
+export const seriesHistoryMarkAsFailed = createThunk(SERIES_HISTORY_MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_SERIES_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = $.ajax({
+ url: '/history/series',
+ data: payload
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [SERIES_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const {
+ historyId,
+ seriesId,
+ seasonNumber
+ } = payload;
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id: historyId
+ }
+ });
+
+ promise.done(() => {
+ dispatch(fetchSeriesHistory({ seriesId, seasonNumber }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_SERIES_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/seriesIndexActions.js b/frontend/src/Store/Actions/seriesIndexActions.js
new file mode 100644
index 000000000..4ba268e73
--- /dev/null
+++ b/frontend/src/Store/Actions/seriesIndexActions.js
@@ -0,0 +1,427 @@
+import moment from 'moment';
+import { createAction } from 'redux-actions';
+import sortByName from 'Utilities/Array/sortByName';
+import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { filters, filterPredicates, sortPredicates } from './seriesActions';
+
+//
+// Variables
+
+export const section = 'seriesIndex';
+
+//
+// State
+
+export const defaultState = {
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ view: 'posters',
+
+ posterOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: false,
+ showMonitored: true,
+ showQualityProfile: true,
+ showSearchAction: false
+ },
+
+ overviewOptions: {
+ detailedProgressBar: false,
+ size: 'medium',
+ showMonitored: true,
+ showNetwork: true,
+ showQualityProfile: true,
+ showPreviousAiring: false,
+ showAdded: false,
+ showSeasonCount: true,
+ showPath: false,
+ showSizeOnDisk: false,
+ showSearchAction: false
+ },
+
+ tableOptions: {
+ showBanners: false,
+ showSearchAction: false
+ },
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'sortTitle',
+ label: 'Series Title',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'network',
+ label: 'Network',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'languageProfileId',
+ label: 'Language Profile',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'nextAiring',
+ label: 'Next Airing',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'previousAiring',
+ label: 'Previous Airing',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'added',
+ label: 'Added',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'seasonCount',
+ label: 'Seasons',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episodeProgress',
+ label: 'Episodes',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episodeCount',
+ label: 'Episode Count',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'latestSeason',
+ label: 'Latest Season',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'sizeOnDisk',
+ label: 'Size on Disk',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'genres',
+ label: 'Genres',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'ratings',
+ label: 'Rating',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'certification',
+ label: 'Certification',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'useSceneNumbering',
+ label: 'Scene Numbering',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ sortPredicates: {
+ ...sortPredicates,
+
+ network: function(item) {
+ const network = item.network;
+
+ return network ? network.toLowerCase() : '';
+ },
+
+ nextAiring: function(item, direction) {
+ const nextAiring = item.nextAiring;
+
+ if (nextAiring) {
+ return moment(nextAiring).unix();
+ }
+
+ if (direction === sortDirections.DESCENDING) {
+ return 0;
+ }
+
+ return Number.MAX_VALUE;
+ },
+
+ episodeProgress: function(item) {
+ const { statistics = {} } = item;
+
+ const {
+ episodeCount = 0,
+ episodeFileCount
+ } = statistics;
+
+ const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
+
+ return progress + episodeCount / 1000000;
+ },
+
+ episodeCount: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.episodeCount;
+ },
+
+ seasonCount: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.seasonCount;
+ },
+
+ sizeOnDisk: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.sizeOnDisk;
+ },
+
+ ratings: function(item) {
+ const { ratings = {} } = item;
+
+ return ratings.value;
+ }
+ },
+
+ selectedFilterKey: 'all',
+
+ filters,
+ filterPredicates,
+
+ filterBuilderProps: [
+ {
+ name: 'monitored',
+ label: 'Monitored',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.SERIES_STATUS
+ },
+ {
+ name: 'network',
+ label: 'Network',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'languageProfileId',
+ label: 'Language Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.LANGUAGE_PROFILE
+ },
+ {
+ name: 'nextAiring',
+ label: 'Next Airing',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'previousAiring',
+ label: 'Previous Airing',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'added',
+ label: 'Added',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'seasonCount',
+ label: 'Season Count',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'episodeProgress',
+ label: 'Episode Progress',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'sizeOnDisk',
+ label: 'Size on Disk',
+ type: filterBuilderTypes.NUMBER,
+ valueType: filterBuilderValueTypes.BYTES
+ },
+ {
+ name: 'genres',
+ label: 'Genres',
+ type: filterBuilderTypes.ARRAY,
+ optionsSelector: function(items) {
+ const tagList = items.reduce((acc, series) => {
+ series.genres.forEach((genre) => {
+ acc.push({
+ id: genre,
+ name: genre
+ });
+ });
+
+ return acc;
+ }, []);
+
+ return tagList.sort(sortByName);
+ }
+ },
+ {
+ name: 'ratings',
+ label: 'Rating',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'certification',
+ label: 'Certification',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ },
+ {
+ name: 'useSceneNumbering',
+ label: 'Scene Numbering',
+ type: filterBuilderTypes.EXACT
+ }
+ ]
+};
+
+export const persistState = [
+ 'seriesIndex.sortKey',
+ 'seriesIndex.sortDirection',
+ 'seriesIndex.selectedFilterKey',
+ 'seriesIndex.customFilters',
+ 'seriesIndex.view',
+ 'seriesIndex.columns',
+ 'seriesIndex.posterOptions',
+ 'seriesIndex.overviewOptions',
+ 'seriesIndex.tableOptions'
+];
+
+//
+// Actions Types
+
+export const SET_SERIES_SORT = 'seriesIndex/setSeriesSort';
+export const SET_SERIES_FILTER = 'seriesIndex/setSeriesFilter';
+export const SET_SERIES_VIEW = 'seriesIndex/setSeriesView';
+export const SET_SERIES_TABLE_OPTION = 'seriesIndex/setSeriesTableOption';
+export const SET_SERIES_POSTER_OPTION = 'seriesIndex/setSeriesPosterOption';
+export const SET_SERIES_OVERVIEW_OPTION = 'seriesIndex/setSeriesOverviewOption';
+
+//
+// Action Creators
+
+export const setSeriesSort = createAction(SET_SERIES_SORT);
+export const setSeriesFilter = createAction(SET_SERIES_FILTER);
+export const setSeriesView = createAction(SET_SERIES_VIEW);
+export const setSeriesTableOption = createAction(SET_SERIES_TABLE_OPTION);
+export const setSeriesPosterOption = createAction(SET_SERIES_POSTER_OPTION);
+export const setSeriesOverviewOption = createAction(SET_SERIES_OVERVIEW_OPTION);
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_SERIES_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_SERIES_FILTER]: createSetClientSideCollectionFilterReducer(section),
+
+ [SET_SERIES_VIEW]: function(state, { payload }) {
+ return Object.assign({}, state, { view: payload.view });
+ },
+
+ [SET_SERIES_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [SET_SERIES_POSTER_OPTION]: function(state, { payload }) {
+ const posterOptions = state.posterOptions;
+
+ return {
+ ...state,
+ posterOptions: {
+ ...posterOptions,
+ ...payload
+ }
+ };
+ },
+
+ [SET_SERIES_OVERVIEW_OPTION]: function(state, { payload }) {
+ const overviewOptions = state.overviewOptions;
+
+ return {
+ ...state,
+ overviewOptions: {
+ ...overviewOptions,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js
new file mode 100644
index 000000000..b8640dead
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActions.js
@@ -0,0 +1,134 @@
+import { createAction } from 'redux-actions';
+import { handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import delayProfiles from './Settings/delayProfiles';
+import downloadClients from './Settings/downloadClients';
+import downloadClientOptions from './Settings/downloadClientOptions';
+import general from './Settings/general';
+import indexerOptions from './Settings/indexerOptions';
+import indexers from './Settings/indexers';
+import languageProfiles from './Settings/languageProfiles';
+import mediaManagement from './Settings/mediaManagement';
+import metadata from './Settings/metadata';
+import naming from './Settings/naming';
+import namingExamples from './Settings/namingExamples';
+import notifications from './Settings/notifications';
+import qualityDefinitions from './Settings/qualityDefinitions';
+import qualityProfiles from './Settings/qualityProfiles';
+import releaseProfiles from './Settings/releaseProfiles';
+import remotePathMappings from './Settings/remotePathMappings';
+import ui from './Settings/ui';
+
+export * from './Settings/delayProfiles';
+export * from './Settings/downloadClients';
+export * from './Settings/downloadClientOptions';
+export * from './Settings/general';
+export * from './Settings/indexerOptions';
+export * from './Settings/indexers';
+export * from './Settings/languageProfiles';
+export * from './Settings/mediaManagement';
+export * from './Settings/metadata';
+export * from './Settings/naming';
+export * from './Settings/namingExamples';
+export * from './Settings/notifications';
+export * from './Settings/qualityDefinitions';
+export * from './Settings/qualityProfiles';
+export * from './Settings/releaseProfiles';
+export * from './Settings/remotePathMappings';
+export * from './Settings/ui';
+
+//
+// Variables
+
+export const section = 'settings';
+
+//
+// State
+
+export const defaultState = {
+ advancedSettings: false,
+
+ delayProfiles: delayProfiles.defaultState,
+ downloadClients: downloadClients.defaultState,
+ downloadClientOptions: downloadClientOptions.defaultState,
+ general: general.defaultState,
+ indexerOptions: indexerOptions.defaultState,
+ indexers: indexers.defaultState,
+ languageProfiles: languageProfiles.defaultState,
+ mediaManagement: mediaManagement.defaultState,
+ metadata: metadata.defaultState,
+ naming: naming.defaultState,
+ namingExamples: namingExamples.defaultState,
+ notifications: notifications.defaultState,
+ qualityDefinitions: qualityDefinitions.defaultState,
+ qualityProfiles: qualityProfiles.defaultState,
+ releaseProfiles: releaseProfiles.defaultState,
+ remotePathMappings: remotePathMappings.defaultState,
+ ui: ui.defaultState
+};
+
+export const persistState = [
+ 'settings.advancedSettings'
+];
+
+//
+// Actions Types
+
+export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings';
+
+//
+// Action Creators
+
+export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...delayProfiles.actionHandlers,
+ ...downloadClients.actionHandlers,
+ ...downloadClientOptions.actionHandlers,
+ ...general.actionHandlers,
+ ...indexerOptions.actionHandlers,
+ ...indexers.actionHandlers,
+ ...languageProfiles.actionHandlers,
+ ...mediaManagement.actionHandlers,
+ ...metadata.actionHandlers,
+ ...naming.actionHandlers,
+ ...namingExamples.actionHandlers,
+ ...notifications.actionHandlers,
+ ...qualityDefinitions.actionHandlers,
+ ...qualityProfiles.actionHandlers,
+ ...releaseProfiles.actionHandlers,
+ ...remotePathMappings.actionHandlers,
+ ...ui.actionHandlers
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => {
+ return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
+ },
+
+ ...delayProfiles.reducers,
+ ...downloadClients.reducers,
+ ...downloadClientOptions.reducers,
+ ...general.reducers,
+ ...indexerOptions.reducers,
+ ...indexers.reducers,
+ ...languageProfiles.reducers,
+ ...mediaManagement.reducers,
+ ...metadata.reducers,
+ ...naming.reducers,
+ ...namingExamples.reducers,
+ ...notifications.reducers,
+ ...qualityDefinitions.reducers,
+ ...qualityProfiles.reducers,
+ ...releaseProfiles.reducers,
+ ...remotePathMappings.reducers,
+ ...ui.reducers
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js
new file mode 100644
index 000000000..de156cde2
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActions.js
@@ -0,0 +1,375 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { setAppValue } from 'Store/Actions/appActions';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import { set } from './baseActions';
+
+//
+// Variables
+
+export const section = 'system';
+const backupsSection = 'system.backups';
+
+//
+// State
+
+export const defaultState = {
+ status: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ health: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ diskSpace: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ tasks: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ backups: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isRestoring: false,
+ restoreError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: []
+ },
+
+ updates: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ logs: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 50,
+ sortKey: 'time',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'level',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'logger',
+ label: 'Component',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ isVisible: true
+ },
+ {
+ name: 'time',
+ label: 'Time',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'all',
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'info',
+ label: 'Info',
+ filters: [
+ {
+ key: 'level',
+ value: 'info',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'warn',
+ label: 'Warn',
+ filters: [
+ {
+ key: 'level',
+ value: 'warn',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'error',
+ label: 'Error',
+ filters: [
+ {
+ key: 'level',
+ value: 'error',
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ },
+
+ logFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ updateLogFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'system.logs.pageSize',
+ 'system.logs.sortKey',
+ 'system.logs.sortDirection',
+ 'system.logs.selectedFilterKey'
+];
+
+//
+// Actions Types
+
+export const FETCH_STATUS = 'system/status/fetchStatus';
+export const FETCH_HEALTH = 'system/health/fetchHealth';
+export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace';
+
+export const FETCH_TASK = 'system/tasks/fetchTask';
+export const FETCH_TASKS = 'system/tasks/fetchTasks';
+
+export const FETCH_BACKUPS = 'system/backups/fetchBackups';
+export const RESTORE_BACKUP = 'system/backups/restoreBackup';
+export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
+export const DELETE_BACKUP = 'system/backups/deleteBackup';
+
+export const FETCH_UPDATES = 'system/updates/fetchUpdates';
+
+export const FETCH_LOGS = 'system/logs/fetchLogs';
+export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage';
+export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage';
+export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage';
+export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage';
+export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage';
+export const SET_LOGS_SORT = 'system/logs/setLogsSort';
+export const SET_LOGS_FILTER = 'system/logs/setLogsFilter';
+export const SET_LOGS_TABLE_OPTION = 'system/logs/ssetLogsTableOption';
+
+export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles';
+export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles';
+
+export const RESTART = 'system/restart';
+export const SHUTDOWN = 'system/shutdown';
+
+//
+// Action Creators
+
+export const fetchStatus = createThunk(FETCH_STATUS);
+export const fetchHealth = createThunk(FETCH_HEALTH);
+export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE);
+
+export const fetchTask = createThunk(FETCH_TASK);
+export const fetchTasks = createThunk(FETCH_TASKS);
+
+export const fetchBackups = createThunk(FETCH_BACKUPS);
+export const restoreBackup = createThunk(RESTORE_BACKUP);
+export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP);
+export const deleteBackup = createThunk(DELETE_BACKUP);
+
+export const fetchUpdates = createThunk(FETCH_UPDATES);
+
+export const fetchLogs = createThunk(FETCH_LOGS);
+export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE);
+export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE);
+export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE);
+export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE);
+export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE);
+export const setLogsSort = createThunk(SET_LOGS_SORT);
+export const setLogsFilter = createThunk(SET_LOGS_FILTER);
+export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION);
+
+export const fetchLogFiles = createThunk(FETCH_LOG_FILES);
+export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES);
+
+export const restart = createThunk(RESTART);
+export const shutdown = createThunk(SHUTDOWN);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_STATUS]: createFetchHandler('system.status', '/system/status'),
+ [FETCH_HEALTH]: createFetchHandler('system.health', '/health'),
+ [FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'),
+ [FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'),
+ [FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'),
+
+ [FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'),
+
+ [RESTORE_BACKUP]: function(getState, payload, dispatch) {
+ const {
+ id,
+ file
+ } = payload;
+
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: true
+ }));
+
+ let ajaxOptions = null;
+
+ if (id) {
+ ajaxOptions = {
+ url: `/system/backup/restore/${id}`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify({
+ id
+ })
+ };
+ } else if (file) {
+ const formData = new FormData();
+ formData.append('restore', file);
+
+ ajaxOptions = {
+ url: '/system/backup/restore/upload',
+ method: 'POST',
+ processData: false,
+ contentType: false,
+ data: formData
+ };
+ } else {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: 'Error restoring backup'
+ }));
+ }
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: xhr
+ }));
+ });
+ },
+
+ [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
+
+ [FETCH_UPDATES]: createFetchHandler('system.updates', '/update'),
+ [FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
+ [FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
+
+ ...createServerSideCollectionHandlers(
+ 'system.logs',
+ '/log',
+ fetchLogs,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_LOGS,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_LOGS_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER
+ }
+ ),
+
+ [RESTART]: function(getState, payload, dispatch) {
+ const promise = $.ajax({
+ url: '/system/restart',
+ method: 'POST'
+ });
+
+ promise.done(() => {
+ dispatch(setAppValue({ isRestarting: true }));
+ });
+ },
+
+ [SHUTDOWN]: function() {
+ $.ajax({
+ url: '/system/shutdown',
+ method: 'POST'
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RESTORE_BACKUP]: function(state, { payload }) {
+ return {
+ ...state,
+ backups: {
+ ...state.backups,
+ isRestoring: false,
+ restoreError: null
+ }
+ };
+ },
+
+ [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs')
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js
new file mode 100644
index 000000000..b9d217bd3
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActions.js
@@ -0,0 +1,75 @@
+import $ from 'jquery';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'tags';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+
+ details: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }
+};
+
+//
+// Actions Types
+
+export const FETCH_TAGS = 'tags/fetchTags';
+export const ADD_TAG = 'tags/addTag';
+export const DELETE_TAG = 'tags/deleteTag';
+export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails';
+
+//
+// Action Creators
+
+export const fetchTags = createThunk(FETCH_TAGS);
+export const addTag = createThunk(ADD_TAG);
+export const deleteTag = createThunk(DELETE_TAG);
+export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_TAGS]: createFetchHandler(section, '/tag'),
+
+ [ADD_TAG]: function(getState, payload, dispatch) {
+ const promise = $.ajax({
+ url: '/tag',
+ method: 'POST',
+ data: JSON.stringify(payload.tag)
+ });
+
+ promise.done((data) => {
+ const tags = getState().tags.items.slice();
+ tags.push(data);
+
+ dispatch(update({ section, data: tags }));
+ payload.onTagCreated(data);
+ });
+ },
+
+ [DELETE_TAG]: createRemoveItemHandler(section, '/tag'),
+ [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail')
+
+});
+
+//
+// Reducers
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js
new file mode 100644
index 000000000..3d1d19cc5
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActions.js
@@ -0,0 +1,317 @@
+import { createAction } from 'redux-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createBatchToggleEpisodeMonitoredHandler from './Creators/createBatchToggleEpisodeMonitoredHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'wanted';
+
+//
+// State
+
+export const defaultState = {
+ missing: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'airDateUtc',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'series.sortTitle',
+ label: 'Series Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episode',
+ label: 'Episode',
+ isVisible: true
+ },
+ {
+ name: 'episodeTitle',
+ label: 'Episode Title',
+ isVisible: true
+ },
+ {
+ name: 'airDateUtc',
+ label: 'Air Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: 'Monitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: 'Unmonitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ },
+
+ cutoffUnmet: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'airDateUtc',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'series.sortTitle',
+ label: 'Series Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episode',
+ label: 'Episode',
+ isVisible: true
+ },
+ {
+ name: 'episodeTitle',
+ label: 'Episode Title',
+ isVisible: true
+ },
+ {
+ name: 'airDateUtc',
+ label: 'Air Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: 'Monitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: 'Unmonitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'wanted.missing.pageSize',
+ 'wanted.missing.sortKey',
+ 'wanted.missing.sortDirection',
+ 'wanted.missing.selectedFilterKey',
+ 'wanted.missing.columns',
+ 'wanted.cutoffUnmet.pageSize',
+ 'wanted.cutoffUnmet.sortKey',
+ 'wanted.cutoffUnmet.sortDirection',
+ 'wanted.cutoffUnmet.selectedFilterKey',
+ 'wanted.cutoffUnmet.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_MISSING = 'wanted/missing/fetchMissing';
+export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
+export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
+export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
+export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
+export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
+export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
+export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
+export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
+export const CLEAR_MISSING = 'wanted/missing/clearMissing';
+
+export const BATCH_TOGGLE_MISSING_EPISODES = 'wanted/missing/batchToggleMissingEpisodes';
+
+export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
+export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
+export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
+export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
+export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
+export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
+export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
+export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
+export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
+export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
+
+export const BATCH_TOGGLE_CUTOFF_UNMET_EPISODES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetEpisodes';
+
+//
+// Action Creators
+
+export const fetchMissing = createThunk(FETCH_MISSING);
+export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
+export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
+export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
+export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
+export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
+export const setMissingSort = createThunk(SET_MISSING_SORT);
+export const setMissingFilter = createThunk(SET_MISSING_FILTER);
+export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
+export const clearMissing = createAction(CLEAR_MISSING);
+
+export const batchToggleMissingEpisodes = createThunk(BATCH_TOGGLE_MISSING_EPISODES);
+
+export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
+export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
+export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
+export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
+export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
+export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
+
+export const batchToggleCutoffUnmetEpisodes = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_EPISODES);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.missing',
+ '/wanted/missing',
+ fetchMissing,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_MISSING_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.missing', fetchMissing),
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.cutoffUnmet',
+ '/wanted/cutoff',
+ fetchCutoffUnmet,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_CUTOFF_UNMET_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
+ [SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
+
+ [CLEAR_MISSING]: createClearReducer(
+ 'wanted.missing',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ ),
+
+ [CLEAR_CUTOFF_UNMET]: createClearReducer(
+ 'wanted.cutoffUnmet',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ )
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js
new file mode 100644
index 000000000..ffb2aa0d0
--- /dev/null
+++ b/frontend/src/Store/Middleware/createPersistState.js
@@ -0,0 +1,101 @@
+import _ from 'lodash';
+import persistState from 'redux-localstorage';
+import actions from 'Store/Actions';
+import migrate from 'Store/Migrators/migrate';
+
+const columnPaths = [];
+
+const paths = _.reduce([...actions], (acc, action) => {
+ if (action.persistState) {
+ action.persistState.forEach((path) => {
+ if (path.match(/\.columns$/)) {
+ columnPaths.push(path);
+ }
+
+ acc.push(path);
+ });
+ }
+
+ return acc;
+}, []);
+
+function mergeColumns(path, initialState, persistedState, computedState) {
+ const initialColumns = _.get(initialState, path);
+ const persistedColumns = _.get(persistedState, path);
+
+ if (!persistedColumns || !persistedColumns.length) {
+ return;
+ }
+
+ const columns = [];
+
+ initialColumns.forEach((initialColumn) => {
+ const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name });
+ const column = Object.assign({}, initialColumn);
+ const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined;
+
+ if (persistedColumn) {
+ column.isVisible = persistedColumn.isVisible;
+ }
+
+ // If there is a persisted column, it's index doesn't exceed the column list
+ // and it's modifiable, insert it in the proper position.
+
+ if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) {
+ columns.splice(persistedColumnIndex, 0, column);
+ } else {
+ columns.push(column);
+ }
+
+ // Set the columns in the persisted state
+ _.set(computedState, path, columns);
+ });
+}
+
+function slicer(paths_) {
+ return (state) => {
+ const subset = {};
+
+ paths_.forEach((path) => {
+ _.set(subset, path, _.get(state, path));
+ });
+
+ return subset;
+ };
+}
+
+function serialize(obj) {
+ return JSON.stringify(obj, null, 2);
+}
+
+function merge(initialState, persistedState) {
+ if (!persistedState) {
+ return initialState;
+ }
+
+ const computedState = {};
+
+ _.merge(computedState, initialState, persistedState);
+
+ columnPaths.forEach((columnPath) => {
+ mergeColumns(columnPath, initialState, persistedState, computedState);
+ });
+
+ return computedState;
+}
+
+const config = {
+ slicer,
+ serialize,
+ merge,
+ key: 'sonarr'
+};
+
+export default function createPersistState() {
+ // Migrate existing local storage before proceeding
+ const persistedState = JSON.parse(localStorage.getItem(config.key));
+ migrate(persistedState);
+ localStorage.setItem(config.key, serialize(persistedState));
+
+ return persistState(paths, config);
+}
diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js
new file mode 100644
index 000000000..ef861379c
--- /dev/null
+++ b/frontend/src/Store/Middleware/createSentryMiddleware.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+import * as sentry from '@sentry/browser';
+import parseUrl from 'Utilities/String/parseUrl';
+
+function cleanseUrl(url) {
+ const properties = parseUrl(url);
+
+ return `${properties.pathname}${properties.search}`;
+}
+
+function cleanseData(data) {
+ const result = _.cloneDeep(data);
+
+ result.transaction = cleanseUrl(result.transaction);
+
+ if (result.exception) {
+ result.exception.values.forEach((exception) => {
+ const stacktrace = exception.stacktrace;
+
+ if (stacktrace) {
+ stacktrace.frames.forEach((frame) => {
+ frame.filename = cleanseUrl(frame.filename);
+ });
+ }
+ });
+ }
+
+ result.request.url = cleanseUrl(result.request.url);
+
+ return result;
+}
+
+function identity(stuff) {
+ return stuff;
+}
+
+function createMiddleware() {
+ return (store) => (next) => (action) => {
+ try {
+ // Adds a breadcrumb for reporting later (if necessary).
+ sentry.addBreadcrumb({
+ category: 'redux',
+ message: action.type
+ });
+
+ return next(action);
+ } catch (err) {
+ console.error(`[sentry] Reporting error to Sentry: ${err}`);
+
+ // Send the report including breadcrumbs.
+ sentry.captureException(err, {
+ extra: {
+ action: identity(action),
+ state: identity(store.getState())
+ }
+ });
+ }
+ };
+}
+
+export default function createSentryMiddleware() {
+ const {
+ analytics,
+ branch,
+ version,
+ release,
+ isProduction
+ } = window.Sonarr;
+
+ if (!analytics) {
+ return;
+ }
+
+ const dsn = isProduction ? 'https://b80ca60625b443c38b242e0d21681eb7@sentry.sonarr.tv/13' :
+ 'https://8dbaacdfe2ff4caf97dc7945aecf9ace@sentry.sonarr.tv/12';
+
+ sentry.init({
+ dsn,
+ environment: isProduction ? 'production' : 'development',
+ release,
+ sendDefaultPii: true,
+ beforeSend: cleanseData
+ });
+
+ sentry.configureScope((scope) => {
+ scope.setTag('branch', branch);
+ scope.setTag('version', version);
+ });
+
+ return createMiddleware();
+}
diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js
new file mode 100644
index 000000000..59937bc45
--- /dev/null
+++ b/frontend/src/Store/Middleware/middlewares.js
@@ -0,0 +1,25 @@
+import { applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import { routerMiddleware } from 'react-router-redux';
+import createSentryMiddleware from './createSentryMiddleware';
+import createPersistState from './createPersistState';
+
+export default function(history) {
+ const middlewares = [];
+ const sentryMiddleware = createSentryMiddleware();
+
+ if (sentryMiddleware) {
+ middlewares.push(sentryMiddleware);
+ }
+
+ middlewares.push(routerMiddleware(history));
+ middlewares.push(thunk);
+
+ // eslint-disable-next-line no-underscore-dangle
+ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+
+ return composeEnhancers(
+ applyMiddleware(...middlewares),
+ createPersistState()
+ );
+}
diff --git a/frontend/src/Store/Migrators/migrate.js b/frontend/src/Store/Migrators/migrate.js
new file mode 100644
index 000000000..e8d637ff0
--- /dev/null
+++ b/frontend/src/Store/Migrators/migrate.js
@@ -0,0 +1,5 @@
+import migrateAddSeriesDefaults from './migrateAddSeriesDefaults';
+
+export default function migrate(persistedState) {
+ migrateAddSeriesDefaults(persistedState);
+}
diff --git a/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js b/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js
new file mode 100644
index 000000000..5aaf0a850
--- /dev/null
+++ b/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js
@@ -0,0 +1,14 @@
+import { get } from 'lodash';
+import monitorOptions from 'Utilities/Series/monitorOptions';
+
+export default function migrateAddSeriesDefaults(persistedState) {
+ const monitor = get(persistedState, 'addSeries.defaults.monitor');
+
+ if (!monitor) {
+ return;
+ }
+
+ if (!monitorOptions.find((option) => option.key === monitor)) {
+ persistedState.addSeries.defaults.monitor = monitorOptions[0].key;
+ }
+}
diff --git a/frontend/src/Store/Selectors/createAllSeriesSelector.js b/frontend/src/Store/Selectors/createAllSeriesSelector.js
new file mode 100644
index 000000000..6a1abdac4
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAllSeriesSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createAllSeriesSelector() {
+ return createSelector(
+ (state) => state.series,
+ (series) => {
+ return series.items;
+ }
+ );
+}
+
+export default createAllSeriesSelector;
diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
new file mode 100644
index 000000000..929b0afe0
--- /dev/null
+++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
@@ -0,0 +1,130 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
+import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
+
+function getSortClause(sortKey, sortDirection, sortPredicates) {
+ if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) {
+ return function(item) {
+ return sortPredicates[sortKey](item, sortDirection);
+ };
+ }
+
+ return function(item) {
+ return item[sortKey];
+ };
+}
+
+function filter(items, state) {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ filterPredicates
+ } = state;
+
+ if (!selectedFilterKey) {
+ return items;
+ }
+
+ const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
+
+ return _.filter(items, (item) => {
+ let i = 0;
+ let accepted = true;
+
+ while (accepted && i < selectedFilters.length) {
+ const {
+ key,
+ value,
+ type = filterTypes.EQUAL
+ } = selectedFilters[i];
+
+ if (filterPredicates && filterPredicates.hasOwnProperty(key)) {
+ const predicate = filterPredicates[key];
+
+ if (Array.isArray(value)) {
+ accepted = value.some((v) => predicate(item, v, type));
+ } else {
+ accepted = predicate(item, value, type);
+ }
+ } else if (item.hasOwnProperty(key)) {
+ const predicate = filterTypePredicates[type];
+
+ if (Array.isArray(value)) {
+ accepted = value.some((v) => predicate(item[key], v));
+ } else {
+ accepted = predicate(item[key], value);
+ }
+ } else {
+ // Default to false if the filter can't be tested
+ accepted = false;
+ }
+
+ i++;
+ }
+
+ return accepted;
+ });
+}
+
+function sort(items, state) {
+ const {
+ sortKey,
+ sortDirection,
+ sortPredicates,
+ secondarySortKey,
+ secondarySortDirection
+ } = state;
+
+ const clauses = [];
+ const orders = [];
+
+ clauses.push(getSortClause(sortKey, sortDirection, sortPredicates));
+ orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+
+ if (secondarySortKey &&
+ secondarySortDirection &&
+ (sortKey !== secondarySortKey ||
+ sortDirection !== secondarySortDirection)) {
+ clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates));
+ orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+ }
+
+ return _.orderBy(items, clauses, orders);
+}
+
+function createCustomFiltersSelector(type, alternateType) {
+ return createSelector(
+ (state) => state.customFilters.items,
+ (customFilters) => {
+ return customFilters.filter((customFilter) => {
+ return customFilter.type === type || customFilter.type === alternateType;
+ });
+ }
+ );
+}
+
+function createClientSideCollectionSelector(section, uiSection) {
+ return createSelector(
+ (state) => _.get(state, section),
+ (state) => _.get(state, uiSection),
+ createCustomFiltersSelector(section, uiSection),
+ (sectionState, uiSectionState = {}, customFilters) => {
+ const state = Object.assign({}, sectionState, uiSectionState, { customFilters });
+
+ const filtered = filter(state.items, state);
+ const sorted = sort(filtered, state);
+
+ return {
+ ...sectionState,
+ ...uiSectionState,
+ customFilters,
+ items: sorted,
+ totalItems: state.items.length
+ };
+ }
+ );
+}
+
+export default createClientSideCollectionSelector;
diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js
new file mode 100644
index 000000000..6037d5820
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+import createCommandSelector from './createCommandSelector';
+
+function createCommandExecutingSelector(name, contraints = {}) {
+ return createSelector(
+ createCommandSelector(name, contraints),
+ (command) => {
+ return isCommandExecuting(command);
+ }
+ );
+}
+
+export default createCommandExecutingSelector;
diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js
new file mode 100644
index 000000000..709dfebaf
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandSelector.js
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import { findCommand } from 'Utilities/Command';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandSelector(name, contraints = {}) {
+ return createSelector(
+ createCommandsSelector(),
+ (commands) => {
+ return findCommand(commands, { name, ...contraints });
+ }
+ );
+}
+
+export default createCommandSelector;
diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.js
new file mode 100644
index 000000000..7b9edffd9
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createCommandsSelector() {
+ return createSelector(
+ (state) => state.commands,
+ (commands) => {
+ return commands.items;
+ }
+ );
+}
+
+export default createCommandsSelector;
diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.js
new file mode 100644
index 000000000..ce26b2e2c
--- /dev/null
+++ b/frontend/src/Store/Selectors/createDimensionsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createDimensionsSelector() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ (dimensions) => {
+ return dimensions;
+ }
+ );
+}
+
+export default createDimensionsSelector;
diff --git a/frontend/src/Store/Selectors/createEpisodeFileSelector.js b/frontend/src/Store/Selectors/createEpisodeFileSelector.js
new file mode 100644
index 000000000..f58f000ff
--- /dev/null
+++ b/frontend/src/Store/Selectors/createEpisodeFileSelector.js
@@ -0,0 +1,17 @@
+import { createSelector } from 'reselect';
+
+function createEpisodeFileSelector() {
+ return createSelector(
+ (state, { episodeFileId }) => episodeFileId,
+ (state) => state.episodeFiles,
+ (episodeFileId, episodeFiles) => {
+ if (!episodeFileId) {
+ return;
+ }
+
+ return episodeFiles.items.find((episodeFile) => episodeFile.id === episodeFileId);
+ }
+ );
+}
+
+export default createEpisodeFileSelector;
diff --git a/frontend/src/Store/Selectors/createEpisodeSelector.js b/frontend/src/Store/Selectors/createEpisodeSelector.js
new file mode 100644
index 000000000..6725cadd9
--- /dev/null
+++ b/frontend/src/Store/Selectors/createEpisodeSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import episodeEntities from 'Episode/episodeEntities';
+
+function createEpisodeSelector() {
+ return createSelector(
+ (state, { episodeId }) => episodeId,
+ (state, { episodeEntity = episodeEntities.EPISODES }) => _.get(state, episodeEntity, { items: [] }),
+ (episodeId, episodes) => {
+ return _.find(episodes.items, { id: episodeId });
+ }
+ );
+}
+
+export default createEpisodeSelector;
diff --git a/frontend/src/Store/Selectors/createExistingSeriesSelector.js b/frontend/src/Store/Selectors/createExistingSeriesSelector.js
new file mode 100644
index 000000000..77d18acee
--- /dev/null
+++ b/frontend/src/Store/Selectors/createExistingSeriesSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from './createAllSeriesSelector';
+
+function createExistingSeriesSelector() {
+ return createSelector(
+ (state, { tvdbId }) => tvdbId,
+ createAllSeriesSelector(),
+ (tvdbId, series) => {
+ return _.some(series, { tvdbId });
+ }
+ );
+}
+
+export default createExistingSeriesSelector;
diff --git a/frontend/src/Store/Selectors/createImportSeriesItemSelector.js b/frontend/src/Store/Selectors/createImportSeriesItemSelector.js
new file mode 100644
index 000000000..dc6c28a05
--- /dev/null
+++ b/frontend/src/Store/Selectors/createImportSeriesItemSelector.js
@@ -0,0 +1,28 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from './createAllSeriesSelector';
+
+function createImportSeriesItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.addSeries,
+ (state) => state.importSeries,
+ createAllSeriesSelector(),
+ (id, addSeries, importSeries, series) => {
+ const item = _.find(importSeries.items, { id }) || {};
+ const selectedSeries = item && item.selectedSeries;
+ const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
+
+ return {
+ defaultMonitor: addSeries.defaults.monitor,
+ defaultQualityProfileId: addSeries.defaults.qualityProfileId,
+ defaultSeriesType: addSeries.defaults.seriesType,
+ defaultSeasonFolder: addSeries.defaults.seasonFolder,
+ ...item,
+ isExistingSeries
+ };
+ }
+ );
+}
+
+export default createImportSeriesItemSelector;
diff --git a/frontend/src/Store/Selectors/createLanguageProfileSelector.js b/frontend/src/Store/Selectors/createLanguageProfileSelector.js
new file mode 100644
index 000000000..2ad04d506
--- /dev/null
+++ b/frontend/src/Store/Selectors/createLanguageProfileSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createLanguageProfileSelector() {
+ return createSelector(
+ (state, { languageProfileId }) => languageProfileId,
+ (state) => state.settings.languageProfiles.items,
+ (languageProfileId, languageProfiles) => {
+ return _.find(languageProfiles, { id: languageProfileId });
+ }
+ );
+}
+
+export default createLanguageProfileSelector;
diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js
new file mode 100644
index 000000000..540b61d26
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js
@@ -0,0 +1,19 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from './createAllSeriesSelector';
+
+function createProfileInUseSelector(profileProp) {
+ return createSelector(
+ (state, { id }) => id,
+ createAllSeriesSelector(),
+ (id, series) => {
+ if (!id) {
+ return false;
+ }
+
+ return _.some(series, { [profileProp]: id });
+ }
+ );
+}
+
+export default createProfileInUseSelector;
diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
new file mode 100644
index 000000000..46659609f
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
@@ -0,0 +1,63 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createProviderSettingsSelector(sectionName) {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings[sectionName],
+ (id, section) => {
+ if (!id) {
+ const item = _.isArray(section.schema) ? section.selectedSchema : section.schema;
+ const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError);
+
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges,
+ ...settings,
+ item: settings.settings
+ };
+ }
+
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ ...settings,
+ item: settings.settings
+ };
+ }
+ );
+}
+
+export default createProviderSettingsSelector;
diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js
new file mode 100644
index 000000000..9308d63ac
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createQualityProfileSelector() {
+ return createSelector(
+ (state, { qualityProfileId }) => qualityProfileId,
+ (state) => state.settings.qualityProfiles.items,
+ (qualityProfileId, qualityProfiles) => {
+ return _.find(qualityProfiles, { id: qualityProfileId });
+ }
+ );
+}
+
+export default createQualityProfileSelector;
diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js
new file mode 100644
index 000000000..1172feb1e
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQueueItemSelector.js
@@ -0,0 +1,19 @@
+import { createSelector } from 'reselect';
+
+function createQueueItemSelector() {
+ return createSelector(
+ (state, { episodeId }) => episodeId,
+ (state) => state.queue.details.items,
+ (episodeId, details) => {
+ if (!episodeId) {
+ return null;
+ }
+
+ return details.find((item) => {
+ return item.episode.id === episodeId;
+ });
+ }
+ );
+}
+
+export default createQueueItemSelector;
diff --git a/frontend/src/Store/Selectors/createSeriesCountSelector.js b/frontend/src/Store/Selectors/createSeriesCountSelector.js
new file mode 100644
index 000000000..f59d8ce5e
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSeriesCountSelector.js
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from './createAllSeriesSelector';
+
+function createSeriesCountSelector() {
+ return createSelector(
+ createAllSeriesSelector(),
+ (series) => {
+ return series.length;
+ }
+ );
+}
+
+export default createSeriesCountSelector;
diff --git a/frontend/src/Store/Selectors/createSeriesSelector.js b/frontend/src/Store/Selectors/createSeriesSelector.js
new file mode 100644
index 000000000..1c1ab5bb8
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSeriesSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from './createAllSeriesSelector';
+
+function createSeriesSelector() {
+ return createSelector(
+ (state, { seriesId }) => seriesId,
+ createAllSeriesSelector(),
+ (seriesId, series) => {
+ return _.find(series, { id: seriesId });
+ }
+ );
+}
+
+export default createSeriesSelector;
diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
new file mode 100644
index 000000000..a9f6cbff6
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
@@ -0,0 +1,32 @@
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createSettingsSectionSelector(section) {
+ return createSelector(
+ (state) => state.settings[section],
+ (sectionSettings) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ item,
+ pendingChanges,
+ isSaving,
+ saveError
+ } = sectionSettings;
+
+ const settings = selectSettings(item, pendingChanges, saveError);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ ...settings
+ };
+ }
+ );
+}
+
+export default createSettingsSectionSelector;
diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.js
new file mode 100644
index 000000000..df586bbb9
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSystemStatusSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createSystemStatusSelector() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return status.item;
+ }
+ );
+}
+
+export default createSystemStatusSelector;
diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.js
new file mode 100644
index 000000000..dd178944c
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTagDetailsSelector.js
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+
+function createTagDetailsSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.tags.details.items,
+ (id, tagDetails) => {
+ return tagDetails.find((t) => t.id === id);
+ }
+ );
+}
+
+export default createTagDetailsSelector;
diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.js
new file mode 100644
index 000000000..fbfd91cdb
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTagsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createTagsSelector() {
+ return createSelector(
+ (state) => state.tags.items,
+ (tags) => {
+ return tags;
+ }
+ );
+}
+
+export default createTagsSelector;
diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.js
new file mode 100644
index 000000000..b256d0e98
--- /dev/null
+++ b/frontend/src/Store/Selectors/createUISettingsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createUISettingsSelector() {
+ return createSelector(
+ (state) => state.settings.ui,
+ (ui) => {
+ return ui.item;
+ }
+ );
+}
+
+export default createUISettingsSelector;
diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js
new file mode 100644
index 000000000..3e30478b7
--- /dev/null
+++ b/frontend/src/Store/Selectors/selectSettings.js
@@ -0,0 +1,104 @@
+import _ from 'lodash';
+
+function getValidationFailures(saveError) {
+ if (!saveError || saveError.status !== 400) {
+ return [];
+ }
+
+ return _.cloneDeep(saveError.responseJSON);
+}
+
+function mapFailure(failure) {
+ return {
+ message: failure.errorMessage,
+ link: failure.infoLink,
+ detailedMessage: failure.detailedDescription
+ };
+}
+
+function selectSettings(item, pendingChanges, saveError) {
+ const validationFailures = getValidationFailures(saveError);
+
+ // Merge all settings from the item along with pending
+ // changes to ensure any settings that were not included
+ // with the item are included.
+ const allSettings = Object.assign({}, item, pendingChanges);
+
+ const settings = _.reduce(allSettings, (result, value, key) => {
+ if (key === 'fields') {
+ return result;
+ }
+
+ // Return a flattened value
+ if (key === 'implementationName') {
+ result.implementationName = item[key];
+
+ return result;
+ }
+
+ const setting = {
+ value: item[key],
+ errors: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning;
+ }), mapFailure),
+
+ warnings: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning;
+ }), mapFailure)
+ };
+
+ if (pendingChanges.hasOwnProperty(key)) {
+ setting.previousValue = setting.value;
+ setting.value = pendingChanges[key];
+ setting.pending = true;
+ }
+
+ result[key] = setting;
+ return result;
+ }, {});
+
+ const fields = _.reduce(item.fields, (result, f) => {
+ const field = Object.assign({ pending: false }, f);
+ const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name);
+
+ if (hasPendingFieldChange) {
+ field.previousValue = field.value;
+ field.value = pendingChanges.fields[field.name];
+ field.pending = true;
+ }
+
+ field.errors = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning;
+ }), mapFailure);
+
+ field.warnings = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning;
+ }), mapFailure);
+
+ result.push(field);
+ return result;
+ }, []);
+
+ if (fields.length) {
+ settings.fields = fields;
+ }
+
+ const validationErrors = _.filter(validationFailures, (failure) => {
+ return !failure.isWarning;
+ });
+
+ const validationWarnings = _.filter(validationFailures, (failure) => {
+ return failure.isWarning;
+ });
+
+ return {
+ settings,
+ validationErrors,
+ validationWarnings,
+ hasPendingChanges: !_.isEmpty(pendingChanges),
+ hasSettings: !_.isEmpty(settings),
+ pendingChanges
+ };
+}
+
+export default selectSettings;
diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js
new file mode 100644
index 000000000..e05c80323
--- /dev/null
+++ b/frontend/src/Store/createAppStore.js
@@ -0,0 +1,15 @@
+import { createStore } from 'redux';
+import reducers, { defaultState } from 'Store/Actions/reducers';
+import middlewares from 'Store/Middleware/middlewares';
+
+function createAppStore(history) {
+ const appStore = createStore(
+ reducers,
+ defaultState,
+ middlewares(history)
+ );
+
+ return appStore;
+}
+
+export default createAppStore;
diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js
new file mode 100644
index 000000000..39648c008
--- /dev/null
+++ b/frontend/src/Store/scrollPositions.js
@@ -0,0 +1,5 @@
+const scrollPositions = {
+ seriesIndex: 0
+};
+
+export default scrollPositions;
diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js
new file mode 100644
index 000000000..ebcf10917
--- /dev/null
+++ b/frontend/src/Store/thunks.js
@@ -0,0 +1,28 @@
+const thunks = {};
+
+function identity(payload) {
+ return payload;
+}
+
+export function createThunk(type, identityFunction = identity) {
+ return function(payload = {}) {
+ return function(dispatch, getState) {
+ const thunk = thunks[type];
+
+ if (thunk) {
+ return thunk(getState, identityFunction(payload), dispatch);
+ }
+
+ throw Error(`Thunk handler has not been registered for ${type}`);
+ };
+ };
+}
+
+export function handleThunks(handlers) {
+ const types = Object.keys(handlers);
+
+ types.forEach((type) => {
+ thunks[type] = handlers[type];
+ });
+}
+
diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css
new file mode 100644
index 000000000..e44c99be6
--- /dev/null
+++ b/frontend/src/Styles/Mixins/cover.css
@@ -0,0 +1,8 @@
+@define-mixin cover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+}
diff --git a/frontend/src/Styles/Mixins/linkOverlay.css b/frontend/src/Styles/Mixins/linkOverlay.css
new file mode 100644
index 000000000..74c3fd753
--- /dev/null
+++ b/frontend/src/Styles/Mixins/linkOverlay.css
@@ -0,0 +1,11 @@
+@define-mixin linkOverlay {
+ @add-mixin cover;
+
+ pointer-events: none;
+ user-select: none;
+
+ a,
+ button {
+ pointer-events: all;
+ }
+}
diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css
new file mode 100644
index 000000000..62a619103
--- /dev/null
+++ b/frontend/src/Styles/Mixins/scroller.css
@@ -0,0 +1,26 @@
+@define-mixin scrollbar {
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+}
+
+@define-mixin scrollbarTrack {
+ &&::-webkit-scrollbar-track {
+ background-color: transparent;
+ }
+}
+
+@define-mixin scrollbarThumb {
+ &::-webkit-scrollbar-thumb {
+ min-height: 50px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background-color: $scrollbarBackgroundColor;
+ background-clip: padding-box;
+
+ &:hover {
+ background-color: $scrollbarHoverBackgroundColor;
+ }
+ }
+}
diff --git a/frontend/src/Styles/Mixins/truncate.css b/frontend/src/Styles/Mixins/truncate.css
new file mode 100644
index 000000000..1941afc9b
--- /dev/null
+++ b/frontend/src/Styles/Mixins/truncate.css
@@ -0,0 +1,18 @@
+/**
+ * From: https://github.com/suitcss/utils-text/blob/master/lib/text.css
+ *
+ * Text truncation
+ *
+ * Prevent text from wrapping onto multiple lines, and truncate with an
+ * ellipsis.
+ *
+ * 1. Ensure that the node has a maximum width after which truncation can
+ * occur.
+ */
+
+@define-mixin truncate {
+ overflow: hidden !important;
+ max-width: 100%; /* 1 */
+ text-overflow: ellipsis !important;
+ white-space: nowrap !important;
+}
diff --git a/frontend/src/Styles/Variables/animations.js b/frontend/src/Styles/Variables/animations.js
new file mode 100644
index 000000000..52d12827a
--- /dev/null
+++ b/frontend/src/Styles/Variables/animations.js
@@ -0,0 +1,8 @@
+// Use CommonJS since this is consumed by PostCSS via webpack (node.js).
+
+module.exports = {
+ // Durations
+ defaultSpeed: '0.2s',
+ slowSpeed: '0.6s',
+ fastSpeed: '0.1s'
+};
diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js
new file mode 100644
index 000000000..4ded49029
--- /dev/null
+++ b/frontend/src/Styles/Variables/colors.js
@@ -0,0 +1,181 @@
+const sonarrBlue = '#35c5f4';
+
+module.exports = {
+ defaultColor: '#333',
+ disabledColor: '#999',
+ dimColor: '#555',
+ black: '#000',
+ white: '#fff',
+ offWhite: '#f5f7fa',
+ primaryColor: '#5d9cec',
+ selectedColor: '#f9be03',
+ successColor: '#27c24c',
+ dangerColor: '#f05050',
+ warningColor: '#ffa500',
+ infoColor: sonarrBlue,
+ purple: '#7a43b6',
+ pink: '#ff69b4',
+ sonarrBlue,
+ helpTextColor: '#909293',
+ darkGray: '#888',
+ gray: '#adadad',
+ lightGray: '#ddd',
+ disabledInputColor: '#808080',
+
+ // Theme Colors
+
+ themeBlue: sonarrBlue,
+ themeAlternateBlue: '#2193b5',
+ themeRed: '#c4273c',
+ themeDarkColor: '#3a3f51',
+ themeLightColor: '#4f566f',
+
+ torrentColor: '#00853d',
+ usenetColor: '#17b1d9',
+
+ // Links
+ defaultLinkHoverColor: '#fff',
+ linkColor: '#5d9cec',
+ linkHoverColor: '#1b72e2',
+
+ // Sidebar
+
+ sidebarColor: '#e1e2e3',
+ sidebarBackgroundColor: '#3a3f51',
+ sidebarActiveBackgroundColor: '#252833',
+
+ // Toolbar
+ toolbarColor: '#e1e2e3',
+ toolbarBackgroundColor: '#4f566f',
+ toolbarMenuItemBackgroundColor: '#454b60',
+ toolbarMenuItemHoverBackgroundColor: '#3a3f51',
+ toolbarLabelColor: '#8895aa',
+
+ // Accents
+ borderColor: '#e5e5e5',
+ inputBorderColor: '#dde6e9',
+ inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
+ inputFocusBorderColor: '#66afe9',
+ inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
+ inputErrorBorderColor: '#f05050',
+ inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)',
+ inputWarningBorderColor: '#ffa500',
+ inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)',
+ colorImpairedGradient: '#fcfcfc',
+
+ //
+ // Buttons
+
+ defaultBackgroundColor: '#fff',
+ defaultBorderColor: '#eaeaea',
+ defaultHoverBackgroundColor: '#f5f5f5',
+ defaultHoverBorderColor: '#d6d6d6;',
+
+ primaryBackgroundColor: '#5d9cec',
+ primaryBorderColor: '#5899eb',
+ primaryHoverBackgroundColor: '#4b91ea',
+ primaryHoverBorderColor: '#3483e7;',
+
+ successBackgroundColor: '#27c24c',
+ successBorderColor: '#26be4a',
+ successHoverBackgroundColor: '#24b145',
+ successHoverBorderColor: '#1f9c3d;',
+
+ warningBackgroundColor: '#ff902b',
+ warningBorderColor: '#ff8d26',
+ warningHoverBackgroundColor: '#ff8517',
+ warningHoverBorderColor: '#fc7800;',
+
+ dangerBackgroundColor: '#f05050',
+ dangerBorderColor: '#f04b4b',
+ dangerHoverBackgroundColor: '#ee3d3d',
+ dangerHoverBorderColor: '#ec2626;',
+
+ iconButtonDisabledColor: '#7a7a7a',
+ iconButtonHoverColor: '#666',
+ iconButtonHoverLightColor: '#ccc',
+
+ //
+ // Modal
+
+ modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)',
+ modalBackgroundColor: '#fff',
+ modalCloseButtonHoverColor: '#888',
+
+ //
+ // Menu
+ menuItemColor: '#e1e2e3',
+ menuItemHoverColor: '#fbfcfc',
+ menuItemHoverBackgroundColor: '#f5f7fa',
+
+ //
+ // Toolbar
+
+ toobarButtonHoverColor: '#35c5f4',
+ toobarButtonSelectedColor: '#35c5f4',
+
+ //
+ // Scroller
+
+ scrollbarBackgroundColor: '#9ea4b9',
+ scrollbarHoverBackgroundColor: '#656d8c',
+
+ //
+ // Card
+
+ cardShadowColor: '#e1e1e1',
+ cardAlternateBackgroundColor: '#f5f5f5',
+
+ //
+ // Alert
+
+ alertDangerBorderColor: '#ebccd1',
+ alertDangerBackgroundColor: '#f2dede',
+ alertDangerColor: '#a94442',
+
+ alertInfoBorderColor: '#bce8f1',
+ alertInfoBackgroundColor: '#d9edf7',
+ alertInfoColor: '#31708f',
+
+ alertSuccessBorderColor: '#d6e9c6',
+ alertSuccessBackgroundColor: '#dff0d8',
+ alertSuccessColor: '#3c763d',
+
+ alertWarningBorderColor: '#faebcc',
+ alertWarningBackgroundColor: '#fcf8e3',
+ alertWarningColor: '#8a6d3b',
+
+ //
+ // Slider
+
+ sliderAccentColor: '#5d9cec',
+
+ //
+ // Form
+
+ advancedFormLabelColor: '#ff902b',
+ disabledCheckInputColor: '#ddd',
+
+ //
+ // Popover
+
+ popoverTitleBackgroundColor: '#f7f7f7',
+ popoverTitleBorderColor: '#ebebeb',
+ popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)',
+
+ popoverTitleBackgroundInverseColor: '#3a3f51',
+ popoverTitleBorderInverseColor: '#4f566f',
+ popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)',
+
+ //
+ // Calendar
+
+ calendarTodayBackgroundColor: '#ddd',
+
+ //
+ // Table
+
+ tableRowHoverBackgroundColor: '#fafbfc'
+};
diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js
new file mode 100644
index 000000000..83f9e9d4e
--- /dev/null
+++ b/frontend/src/Styles/Variables/dimensions.js
@@ -0,0 +1,53 @@
+module.exports = {
+ // Page
+ pageContentBodyPadding: '20px',
+ pageContentBodyPaddingSmallScreen: '10px',
+
+ // Header
+ headerHeight: '60px',
+
+ // Sidebar
+ sidebarWidth: '210px',
+
+ // Toolbar
+ toolbarHeight: '60px',
+ toolbarButtonWidth: '60px',
+ toolbarSeparatorMargin: '20px',
+
+ // Break Points
+ breakpointExtraSmall: '480px',
+ breakpointSmall: '768px',
+ breakpointMedium: '992px',
+ breakpointLarge: '1200px',
+ breakpointExtraLarge: '1450px',
+
+ // Form
+ formGroupExtraSmallWidth: '550px',
+ formGroupSmallWidth: '650px',
+ formGroupMediumWidth: '800px',
+ formGroupLargeWidth: '1200px',
+ formLabelSmallWidth: '150px',
+ formLabelLargeWidth: '250px',
+ formLabelRightMarginWidth: '20px',
+
+ // Drag
+ dragHandleWidth: '40px',
+ qualityProfileItemHeight: '30px',
+ qualityProfileItemDragSourcePadding: '4px',
+
+ // Progress Bar
+ progressBarSmallHeight: '5px',
+ progressBarMediumHeight: '15px',
+ progressBarLargeHeight: '20px',
+
+ // Jump Bar
+ jumpBarItemHeight: '25px',
+
+ // Modal
+ modalBodyPadding: '30px',
+
+ // Series
+ seriesIndexColumnPadding: '20px',
+ seriesIndexColumnPaddingSmallScreen: '10px',
+ seriesIndexOverviewInfoRowHeight: '21px'
+};
diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js
new file mode 100644
index 000000000..3b0077c5a
--- /dev/null
+++ b/frontend/src/Styles/Variables/fonts.js
@@ -0,0 +1,15 @@
+module.exports = {
+ // Families
+ defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
+ monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
+ passwordFamily: 'text-security-disc',
+
+ // Sizes
+ extraSmallFontSize: '11px',
+ smallFontSize: '12px',
+ defaultFontSize: '14px',
+ intermediateFontSize: '15px',
+ largeFontSize: '16px',
+
+ lineHeight: '1.528571429'
+};
diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css
new file mode 100644
index 000000000..70967f0c4
--- /dev/null
+++ b/frontend/src/Styles/globals.css
@@ -0,0 +1,7 @@
+/* stylelint-disable */
+
+@import '~normalize.css/normalize.css';
+@import 'scaffolding.css';
+@import '../Content/Fonts/fonts.css';
+
+/* stylelint-enable */
diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css
new file mode 100644
index 000000000..8d95f8d12
--- /dev/null
+++ b/frontend/src/Styles/scaffolding.css
@@ -0,0 +1,45 @@
+/* stylelint-disable */
+* {
+ box-sizing: border-box;
+}
+
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+*:focus {
+ outline: none;
+}
+/* stylelint-enable */
+
+html,
+body {
+ color: #515253;
+ font-family: 'Roboto', 'open sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
+}
+
+body {
+ font-size: 14px;
+ line-height: 1.528571429; /* 20/14 */
+}
+
+/* Override normalize */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ margin: 0;
+ font-size: inherit;
+ font-family: inherit;
+ line-height: 1.528571429; /* 20/14 */
+}
+
+/* Better defaults for unordererd lists */
+
+ul {
+ margin: 0;
+ padding-left: 20px;
+}
diff --git a/frontend/src/System/Backup/BackupRow.css b/frontend/src/System/Backup/BackupRow.css
new file mode 100644
index 000000000..d83a22e25
--- /dev/null
+++ b/frontend/src/System/Backup/BackupRow.css
@@ -0,0 +1,12 @@
+.type {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+ text-align: center;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js
new file mode 100644
index 000000000..df5ff974c
--- /dev/null
+++ b/frontend/src/System/Backup/BackupRow.js
@@ -0,0 +1,153 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import RestoreBackupModalConnector from './RestoreBackupModalConnector';
+import styles from './BackupRow.css';
+
+class BackupRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestoreModalOpen: false,
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRestorePress = () => {
+ this.setState({ isRestoreModalOpen: true });
+ }
+
+ onRestoreModalClose = () => {
+ this.setState({ isRestoreModalOpen: false });
+ }
+
+ onDeletePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ }
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ }
+
+ onConfirmDeletePress = () => {
+ const {
+ id,
+ onDeleteBackupPress
+ } = this.props;
+
+ this.setState({ isConfirmDeleteModalOpen: false }, () => {
+ onDeleteBackupPress(id);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ type,
+ name,
+ path,
+ time
+ } = this.props;
+
+ const {
+ isRestoreModalOpen,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ let iconClassName = icons.SCHEDULED;
+ let iconTooltip = 'Scheduled';
+
+ if (type === 'manual') {
+ iconClassName = icons.INTERACTIVE;
+ iconTooltip = 'Manual';
+ } else if (type === 'update') {
+ iconClassName = icons.UPDATE;
+ iconTooltip = 'Before update';
+ }
+
+ return (
+
+
+ {
+
+ }
+
+
+
+
+ {name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+BackupRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ type: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ onDeleteBackupPress: PropTypes.func.isRequired
+};
+
+export default BackupRow;
diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js
new file mode 100644
index 000000000..97167e6f2
--- /dev/null
+++ b/frontend/src/System/Backup/Backups.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import BackupRow from './BackupRow';
+import RestoreBackupModalConnector from './RestoreBackupModalConnector';
+
+const columns = [
+ {
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'time',
+ label: 'Time',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+class Backups extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestoreModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRestorePress = () => {
+ this.setState({ isRestoreModalOpen: true });
+ }
+
+ onRestoreModalClose = () => {
+ this.setState({ isRestoreModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ backupExecuting,
+ onBackupPress,
+ onDeleteBackupPress
+ } = this.props;
+
+ const hasBackups = isPopulated && !!items.length;
+ const noBackups = isPopulated && !items.length;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load backups
+ }
+
+ {
+ noBackups &&
+ No backups are available
+ }
+
+ {
+ hasBackups &&
+
+
+ {
+ items.map((item) => {
+ const {
+ id,
+ type,
+ name,
+ path,
+ time
+ } = item;
+
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+ );
+ }
+
+}
+
+Backups.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ backupExecuting: PropTypes.bool.isRequired,
+ onBackupPress: PropTypes.func.isRequired,
+ onDeleteBackupPress: PropTypes.func.isRequired
+};
+
+export default Backups;
diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js
new file mode 100644
index 000000000..434354f5b
--- /dev/null
+++ b/frontend/src/System/Backup/BackupsConnector.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import Backups from './Backups';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.backups,
+ createCommandExecutingSelector(commandNames.BACKUP),
+ (backups, backupExecuting) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = backups;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ backupExecuting
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchBackups() {
+ dispatch(fetchBackups());
+ },
+
+ onDeleteBackupPress(id) {
+ dispatch(deleteBackup({ id }));
+ },
+
+ onBackupPress() {
+ dispatch(executeCommand({
+ name: commandNames.BACKUP
+ }));
+ }
+ };
+}
+
+class BackupsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchBackups();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.backupExecuting && !this.props.backupExecuting) {
+ this.props.dispatchFetchBackups();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BackupsConnector.propTypes = {
+ backupExecuting: PropTypes.bool.isRequired,
+ dispatchFetchBackups: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector);
diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js
new file mode 100644
index 000000000..48dad4d2a
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector';
+
+function RestoreBackupModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+RestoreBackupModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RestoreBackupModal;
diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js
new file mode 100644
index 000000000..98cbcd11b
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalConnector.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { clearRestoreBackup } from 'Store/Actions/systemActions';
+import RestoreBackupModal from './RestoreBackupModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ dispatch(clearRestoreBackup());
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(RestoreBackupModal);
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css
new file mode 100644
index 000000000..783443e33
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.css
@@ -0,0 +1,24 @@
+.additionalInfo {
+ flex-grow: 1;
+ color: #777;
+}
+
+.steps {
+ margin-top: 20px;
+}
+
+.step {
+ display: flex;
+ font-size: $largeFontSize;
+ line-height: 20px;
+}
+
+.stepState {
+ margin-right: 8px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ composes: modalFooter from 'Components/Modal/ModalFooter.css';
+
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js
new file mode 100644
index 000000000..2c42d7ab5
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.js
@@ -0,0 +1,232 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TextInput from 'Components/Form/TextInput';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+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 styles from './RestoreBackupModalContent.css';
+
+function getErrorMessage(error) {
+ if (!error || !error.responseJSON || !error.responseJSON.message) {
+ return 'Error restoring backup';
+ }
+
+ return error.responseJSON.message;
+}
+
+function getStepIconProps(isExecuting, hasExecuted, error) {
+ if (isExecuting) {
+ return {
+ name: icons.SPINNER,
+ isSpinning: true
+ };
+ }
+
+ if (hasExecuted) {
+ return {
+ name: icons.CHECK,
+ kind: kinds.SUCCESS
+ };
+ }
+
+ if (error) {
+ return {
+ name: icons.FATAL,
+ kinds: kinds.DANGER,
+ title: getErrorMessage(error)
+ };
+ }
+
+ return {
+ name: icons.PENDING
+ };
+}
+
+class RestoreBackupModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ file: null,
+ path: '',
+ isRestored: false,
+ isRestarted: false,
+ isReloading: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isRestoring,
+ restoreError,
+ isRestarting,
+ dispatchRestart
+ } = this.props;
+
+ if (prevProps.isRestoring && !isRestoring && !restoreError) {
+ this.setState({ isRestored: true }, () => {
+ dispatchRestart();
+ });
+ }
+
+ if (prevProps.isRestarting && !isRestarting) {
+ this.setState({
+ isRestarted: true,
+ isReloading: true
+ }, () => {
+ location.reload();
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onPathChange = ({ value, files }) => {
+ this.setState({
+ file: files[0],
+ path: value
+ });
+ }
+
+ onRestorePress = () => {
+ const {
+ id,
+ onRestorePress
+ } = this.props;
+
+ onRestorePress({
+ id,
+ file: this.state.file
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ isRestoring,
+ restoreError,
+ isRestarting,
+ onModalClose
+ } = this.props;
+
+ const {
+ path,
+ isRestored,
+ isRestarted,
+ isReloading
+ } = this.state;
+
+ const isRestoreDisabled = (
+ (!id && !path) ||
+ isRestoring ||
+ isRestarting ||
+ isReloading
+ );
+
+ return (
+
+
+ Restore Backup
+
+
+
+ {
+ !!id && `Would you like to restore the backup '${name}'?`
+ }
+
+ {
+ !id &&
+
+ }
+
+
+
+
+
+
+ Note: Sonarr will automatically restart and reload the UI during the restore process.
+
+
+
+ Cancel
+
+
+
+ Restore
+
+
+
+ );
+ }
+}
+
+RestoreBackupModalContent.propTypes = {
+ id: PropTypes.number,
+ name: PropTypes.string,
+ path: PropTypes.string,
+ isRestoring: PropTypes.bool.isRequired,
+ restoreError: PropTypes.object,
+ isRestarting: PropTypes.bool.isRequired,
+ dispatchRestart: PropTypes.func.isRequired,
+ onRestorePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RestoreBackupModalContent;
diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js
new file mode 100644
index 000000000..7f2b7a6e8
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js
@@ -0,0 +1,37 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { restoreBackup, restart } from 'Store/Actions/systemActions';
+import RestoreBackupModalContent from './RestoreBackupModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.backups,
+ (state) => state.app.isRestarting,
+ (backups, isRestarting) => {
+ const {
+ isRestoring,
+ restoreError
+ } = backups;
+
+ return {
+ isRestoring,
+ restoreError,
+ isRestarting
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onRestorePress(payload) {
+ dispatch(restoreBackup(payload));
+ },
+
+ dispatchRestart() {
+ dispatch(restart());
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent);
diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js
new file mode 100644
index 000000000..1858d483a
--- /dev/null
+++ b/frontend/src/System/Events/LogsTable.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, icons } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import LogsTableRow from './LogsTableRow';
+
+function LogsTable(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ selectedFilterKey,
+ filters,
+ totalRecords,
+ clearLogExecuting,
+ onRefreshPress,
+ onClearLogsPress,
+ onFilterSelect,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No logs found
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+}
+
+LogsTable.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ clearLogExecuting: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onClearLogsPress: PropTypes.func.isRequired
+};
+
+export default LogsTable;
diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js
new file mode 100644
index 000000000..c02d67650
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableConnector.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as systemActions from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogsTable from './LogsTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logs,
+ createCommandExecutingSelector(commandNames.CLEAR_LOGS),
+ (logs, clearLogExecuting) => {
+ return {
+ clearLogExecuting,
+ ...logs
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand,
+ ...systemActions
+};
+
+class LogsTableConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLogs();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) {
+ this.props.gotoLogsFirstPage();
+ }
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoLogsFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoLogsPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoLogsNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoLogsLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoLogsPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setLogsSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setLogsFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setLogsTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoLogsFirstPage();
+ }
+ }
+
+ onRefreshPress = () => {
+ this.props.gotoLogsFirstPage();
+ }
+
+ onClearLogsPress = () => {
+ this.props.executeCommand({ name: commandNames.CLEAR_LOGS });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogsTableConnector.propTypes = {
+ clearLogExecuting: PropTypes.bool.isRequired,
+ fetchLogs: PropTypes.func.isRequired,
+ gotoLogsFirstPage: PropTypes.func.isRequired,
+ gotoLogsPreviousPage: PropTypes.func.isRequired,
+ gotoLogsNextPage: PropTypes.func.isRequired,
+ gotoLogsLastPage: PropTypes.func.isRequired,
+ gotoLogsPage: PropTypes.func.isRequired,
+ setLogsSort: PropTypes.func.isRequired,
+ setLogsFilter: PropTypes.func.isRequired,
+ setLogsTableOption: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector);
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css
new file mode 100644
index 000000000..127c1139f
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.css
@@ -0,0 +1,17 @@
+.detailsText {
+ composes: scroller from 'Components/Scroller/Scroller.css';
+
+ display: block;
+ margin: 0 0 10.5px;
+ padding: 10px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background-color: #f5f5f5;
+ color: #3a3f51;
+ white-space: pre;
+ word-wrap: break-word;
+ word-break: break-all;
+ font-size: 13px;
+ font-family: $monoSpaceFontFamily;
+ line-height: 1.52857143;
+}
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js
new file mode 100644
index 000000000..de6a881df
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.js
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Scroller from 'Components/Scroller/Scroller';
+import Modal from 'Components/Modal/Modal';
+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 styles from './LogsTableDetailsModal.css';
+
+function LogsTableDetailsModal(props) {
+ const {
+ isOpen,
+ message,
+ exception,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ Details
+
+
+
+ Message
+
+
+ {message}
+
+
+ {
+ !!exception &&
+
+
Exception
+
+ {exception}
+
+
+ }
+
+
+
+
+ Close
+
+
+
+
+ );
+}
+
+LogsTableDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ message: PropTypes.string.isRequired,
+ exception: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default LogsTableDetailsModal;
diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css
new file mode 100644
index 000000000..557d690f0
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.css
@@ -0,0 +1,35 @@
+.level {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
+
+.info {
+ color: #1e90ff;
+}
+
+.debug {
+ color: #808080;
+}
+
+.trace {
+ color: #d3d3d3;
+}
+
+.warn {
+ color: $warningColor;
+}
+
+.error {
+ color: $dangerColor;
+}
+
+.fatal {
+ color: $purple;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 45px;
+}
diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js
new file mode 100644
index 000000000..390769727
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.js
@@ -0,0 +1,158 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import LogsTableDetailsModal from './LogsTableDetailsModal';
+import styles from './LogsTableRow.css';
+
+function getIconName(level) {
+ switch (level) {
+ case 'trace':
+ case 'debug':
+ case 'info':
+ return icons.INFO;
+ case 'warn':
+ return icons.DANGER;
+ case 'error':
+ return icons.BUG;
+ case 'fatal':
+ return icons.FATAL;
+ default:
+ return icons.UNKNOWN;
+ }
+}
+
+class LogsTableRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ // Don't re-open the modal if it's already open
+ if (!this.state.isDetailsModalOpen) {
+ this.setState({ isDetailsModalOpen: true });
+ }
+ }
+
+ onModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ level,
+ logger,
+ message,
+ time,
+ exception,
+ columns
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'level') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'logger') {
+ return (
+
+ {logger}
+
+ );
+ }
+
+ if (name === 'message') {
+ return (
+
+ {message}
+
+ );
+ }
+
+ if (name === 'time') {
+ return (
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+LogsTableRow.propTypes = {
+ level: PropTypes.string.isRequired,
+ logger: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ exception: PropTypes.string,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default LogsTableRow;
diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js
new file mode 100644
index 000000000..47482b3fe
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFiles.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import TableBody from 'Components/Table/TableBody';
+import LogsNavMenu from '../LogsNavMenu';
+import LogFilesTableRow from './LogFilesTableRow';
+
+const columns = [
+ {
+ name: 'filename',
+ label: 'Filename',
+ isVisible: true
+ },
+ {
+ name: 'lastWriteTime',
+ label: 'Last Write Time',
+ isVisible: true
+ },
+ {
+ name: 'download',
+ isVisible: true
+ }
+];
+
+class LogFiles extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView,
+ location,
+ onRefreshPress,
+ onDeleteFilesPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Log files are located in: {location}
+
+
+ {
+ currentLogView === 'Log Files' &&
+
+ The log level defaults to 'Info' and can be changed in General Settings
+
+ }
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !isFetching && !items.length &&
+ No log files
+ }
+
+
+ );
+ }
+
+}
+
+LogFiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ currentLogView: PropTypes.string.isRequired,
+ location: PropTypes.string.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onDeleteFilesPress: PropTypes.func.isRequired
+};
+
+export default LogFiles;
diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js
new file mode 100644
index 000000000..628bb571c
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import combinePath from 'Utilities/String/combinePath';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchLogFiles } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogFiles from './LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logFiles,
+ (state) => state.system.status.item,
+ createCommandExecutingSelector(commandNames.DELETE_LOG_FILES),
+ (logFiles, status, deleteFilesExecuting) => {
+ const {
+ isFetching,
+ items
+ } = logFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Log Files',
+ location: combinePath(isWindows, appData, ['logs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLogFiles,
+ executeCommand
+};
+
+class LogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLogFiles();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
+ this.props.fetchLogFiles();
+ }
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchLogFiles();
+ }
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector);
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css
new file mode 100644
index 000000000..779794b7d
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css
@@ -0,0 +1,5 @@
+.download {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js
new file mode 100644
index 000000000..7ae61a531
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './LogFilesTableRow.css';
+
+class LogFilesTableRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ filename,
+ lastWriteTime,
+ downloadUrl
+ } = this.props;
+
+ return (
+
+ {filename}
+
+
+
+
+
+ Download
+
+
+
+ );
+ }
+
+}
+
+LogFilesTableRow.propTypes = {
+ filename: PropTypes.string.isRequired,
+ lastWriteTime: PropTypes.string.isRequired,
+ downloadUrl: PropTypes.string.isRequired
+};
+
+export default LogFilesTableRow;
diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js
new file mode 100644
index 000000000..fa0be453e
--- /dev/null
+++ b/frontend/src/System/Logs/Logs.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import LogFilesConnector from './Files/LogFilesConnector';
+import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector';
+
+class Logs extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default Logs;
diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js
new file mode 100644
index 000000000..b69630248
--- /dev/null
+++ b/frontend/src/System/Logs/LogsNavMenu.js
@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class LogsNavMenu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMenuOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onMenuButtonPress = () => {
+ this.setState({ isMenuOpen: !this.state.isMenuOpen });
+ }
+
+ onMenuItemPress = () => {
+ this.setState({ isMenuOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ current
+ } = this.props;
+
+ return (
+
+
+ {current}
+
+
+
+ Log Files
+
+
+
+ Updater Log Files
+
+
+
+ );
+ }
+}
+
+LogsNavMenu.propTypes = {
+ current: PropTypes.string.isRequired
+};
+
+export default LogsNavMenu;
diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
new file mode 100644
index 000000000..3030c12ce
--- /dev/null
+++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import combinePath from 'Utilities/String/combinePath';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchUpdateLogFiles } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogFiles from '../Files/LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.updateLogFiles,
+ (state) => state.system.status.item,
+ createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES),
+ (updateLogFiles, status, deleteFilesExecuting) => {
+ const {
+ isFetching,
+ items
+ } = updateLogFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Updater Log Files',
+ location: combinePath(isWindows, appData, ['UpdateLogs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchUpdateLogFiles,
+ executeCommand
+};
+
+class UpdateLogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUpdateLogFiles();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
+ this.props.fetchUpdateLogFiles();
+ }
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchUpdateLogFiles();
+ }
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UpdateLogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchUpdateLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector);
diff --git a/frontend/src/System/Status/About/About.css b/frontend/src/System/Status/About/About.css
new file mode 100644
index 000000000..fc20848b0
--- /dev/null
+++ b/frontend/src/System/Status/About/About.css
@@ -0,0 +1,5 @@
+.descriptionList {
+ composes: descriptionList from 'Components/DescriptionList/DescriptionList.css';
+
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js
new file mode 100644
index 000000000..0965baff4
--- /dev/null
+++ b/frontend/src/System/Status/About/About.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import FieldSet from 'Components/FieldSet';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import StartTime from './StartTime';
+import styles from './About.css';
+
+class About extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ version,
+ isMonoRuntime,
+ runtimeVersion,
+ appData,
+ startupPath,
+ mode,
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isMonoRuntime &&
+
+ }
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+ );
+ }
+
+}
+
+About.propTypes = {
+ version: PropTypes.string.isRequired,
+ isMonoRuntime: PropTypes.bool.isRequired,
+ runtimeVersion: PropTypes.string.isRequired,
+ appData: PropTypes.string.isRequired,
+ startupPath: PropTypes.string.isRequired,
+ mode: PropTypes.string.isRequired,
+ startTime: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+export default About;
diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js
new file mode 100644
index 000000000..475d9778b
--- /dev/null
+++ b/frontend/src/System/Status/About/AboutConnector.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchStatus } from 'Store/Actions/systemActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import About from './About';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ createUISettingsSelector(),
+ (status, uiSettings) => {
+ return {
+ ...status.item,
+ timeFormat: uiSettings.timeFormat,
+ longDateFormat: uiSettings.longDateFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchStatus
+};
+
+class AboutConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchStatus();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AboutConnector.propTypes = {
+ fetchStatus: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector);
diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js
new file mode 100644
index 000000000..94b4322d5
--- /dev/null
+++ b/frontend/src/System/Status/About/StartTime.js
@@ -0,0 +1,93 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+
+function getUptime(startTime) {
+ return formatTimeSpan(moment().diff(startTime));
+}
+
+class StartTime extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = props;
+
+ this._timeoutId = null;
+
+ this.state = {
+ uptime: getUptime(startTime),
+ startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
+ };
+ }
+
+ componentDidMount() {
+ this._timeoutId = setTimeout(this.onTimeout, 1000);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ if (
+ startTime !== prevProps.startTime ||
+ timeFormat !== prevProps.timeFormat ||
+ longDateFormat !== prevProps.longDateFormat
+ ) {
+ this.setState({
+ uptime: getUptime(startTime),
+ startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._timeoutId) {
+ this._timeoutId = clearTimeout(this._timeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ onTimeout = () => {
+ this.setState({ uptime: getUptime(this.props.startTime) });
+ this._timeoutId = setTimeout(this.onTimeout, 1000);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ uptime,
+ startTime
+ } = this.state;
+
+ return (
+
+ {uptime}
+
+ );
+ }
+}
+
+StartTime.propTypes = {
+ startTime: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+export default StartTime;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css
new file mode 100644
index 000000000..70ef6f884
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css
@@ -0,0 +1,5 @@
+.space {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js
new file mode 100644
index 000000000..ad1de4d64
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js
@@ -0,0 +1,120 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './DiskSpace.css';
+
+const columns = [
+ {
+ name: 'path',
+ label: 'Location',
+ isVisible: true
+ },
+ {
+ name: 'freeSpace',
+ label: 'Free Space',
+ isVisible: true
+ },
+ {
+ name: 'totalSpace',
+ label: 'Total Space',
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ isVisible: true
+ }
+];
+
+class DiskSpace extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items
+ } = this.props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching &&
+
+
+ {
+ items.map((item) => {
+ const {
+ freeSpace,
+ totalSpace
+ } = item;
+
+ const diskUsage = (100 - freeSpace / totalSpace * 100);
+ let diskUsageKind = kinds.PRIMARY;
+
+ if (diskUsage > 90) {
+ diskUsageKind = kinds.DANGER;
+ } else if (diskUsage > 80) {
+ diskUsageKind = kinds.WARNING;
+ }
+
+ return (
+
+
+ {item.path}
+
+ {
+ item.label &&
+ ` (${item.label})`
+ }
+
+
+
+ {formatBytes(freeSpace)}
+
+
+
+ {formatBytes(totalSpace)}
+
+
+
+
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+DiskSpace.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default DiskSpace;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
new file mode 100644
index 000000000..3049b2ead
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDiskSpace } from 'Store/Actions/systemActions';
+import DiskSpace from './DiskSpace';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.diskSpace,
+ (diskSpace) => {
+ const {
+ isFetching,
+ items
+ } = diskSpace;
+
+ return {
+ isFetching,
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDiskSpace
+};
+
+class DiskSpaceConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDiskSpace();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DiskSpaceConnector.propTypes = {
+ fetchDiskSpace: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector);
diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css
new file mode 100644
index 000000000..1aad8ee77
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.css
@@ -0,0 +1,21 @@
+.legend {
+ display: flex;
+ justify-content: space-between;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 2px;
+ margin-left: 10px;
+ text-align: left;
+}
+
+.status {
+ width: 20px;
+}
+
+.healthOk {
+ margin-bottom: 25px;
+}
+
diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js
new file mode 100644
index 000000000..92c95022c
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.js
@@ -0,0 +1,206 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './Health.css';
+
+function getInternalLink(source) {
+ switch (source) {
+ case 'IndexerRssCheck':
+ case 'IndexerSearchCheck':
+ case 'IndexerStatusCheck':
+ return (
+
+ );
+ case 'DownloadClientCheck':
+ case 'ImportMechanismCheck':
+ return (
+
+ );
+ case 'RootFolderCheck':
+ return (
+
+ );
+ case 'UpdateCheck':
+ return (
+
+ );
+ default:
+ return;
+ }
+}
+
+function getTestLink(source, props) {
+ switch (source) {
+ case 'IndexerStatusCheck':
+ return (
+
+ );
+ case 'DownloadClientCheck':
+ return (
+
+ );
+
+ default:
+ break;
+ }
+}
+
+const columns = [
+ {
+ className: styles.status,
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: 'Actions',
+ isVisible: true
+ }
+];
+
+class Health extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = this.props;
+
+ const healthIssues = !!items.length;
+
+ return (
+
+ Health
+
+ {
+ isFetching && isPopulated &&
+
+ }
+
+ }
+ >
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !healthIssues &&
+
+ No issues with your configuration
+
+ }
+
+ {
+ healthIssues &&
+
+
+ {
+ items.map((item) => {
+ const internalLink = getInternalLink(item.source);
+ const testLink = getTestLink(item.source, this.props);
+
+ return (
+
+
+
+
+
+ {item.message}
+
+
+
+
+ {
+ internalLink
+ }
+
+ {
+ !!testLink &&
+ testLink
+ }
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+Health.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ isTestingAllDownloadClients: PropTypes.bool.isRequired,
+ isTestingAllIndexers: PropTypes.bool.isRequired,
+ dispatchTestAllDownloadClients: PropTypes.func.isRequired,
+ dispatchTestAllIndexers: PropTypes.func.isRequired
+};
+
+export default Health;
diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js
new file mode 100644
index 000000000..d2adc41cc
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthConnector.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions';
+import Health from './Health';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.health,
+ (state) => state.settings.downloadClients.isTestingAll,
+ (state) => state.settings.indexers.isTestingAll,
+ (health, isTestingAllDownloadClients, isTestingAllIndexers) => {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = health;
+
+ return {
+ isFetching,
+ isPopulated,
+ items,
+ isTestingAllDownloadClients,
+ isTestingAllIndexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchHealth: fetchHealth,
+ dispatchTestAllDownloadClients: testAllDownloadClients,
+ dispatchTestAllIndexers: testAllIndexers
+};
+
+class HealthConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchHealth();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchHealth,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+HealthConnector.propTypes = {
+ dispatchFetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector);
diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js
new file mode 100644
index 000000000..181eae916
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthStatusConnector.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ (state) => state.system.health,
+ (app, health) => {
+ const count = health.items.length;
+ let errors = false;
+ let warnings = false;
+
+ health.items.forEach((item) => {
+ if (item.type === 'error') {
+ errors = true;
+ }
+
+ if (item.type === 'warning') {
+ warnings = true;
+ }
+ });
+
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: health.isPopulated,
+ count,
+ errors,
+ warnings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHealth
+};
+
+class HealthStatusConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.fetchHealth();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.isConnected && prevProps.isReconnecting) {
+ this.props.fetchHealth();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HealthStatusConnector.propTypes = {
+ isConnected: PropTypes.bool.isRequired,
+ isReconnecting: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector);
diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js
new file mode 100644
index 000000000..4ac04c4e7
--- /dev/null
+++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js
@@ -0,0 +1,73 @@
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import FieldSet from 'Components/FieldSet';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+
+class MoreInfo extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+ Home page
+
+ sonarr.tv
+
+
+ Wiki
+
+ wiki.sonarr.tv
+
+
+ Forums
+
+ forums.sonarr.tv
+
+
+ Twitter
+
+ @sonarrtv
+
+
+ IRC
+
+ #sonarr on Freenode
+
+
+ Freenode webchat
+
+
+ Donations
+
+ sonarr.tv/donate
+
+
+ Source
+
+ github.com/Sonarr/Sonarr
+
+
+ Feature Requests
+
+ forums.sonarr.tv
+
+
+ github.com/Sonarr/Sonarr/issues
+
+
+
+
+ );
+ }
+}
+
+MoreInfo.propTypes = {
+
+};
+
+export default MoreInfo;
diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js
new file mode 100644
index 000000000..f0b157515
--- /dev/null
+++ b/frontend/src/System/Status/Status.js
@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import HealthConnector from './Health/HealthConnector';
+import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector';
+import AboutConnector from './About/AboutConnector';
+import MoreInfo from './MoreInfo/MoreInfo';
+
+class Status extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default Status;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css
new file mode 100644
index 000000000..30f86efff
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css
@@ -0,0 +1,31 @@
+.trigger {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.triggerContent {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.queued,
+.started,
+.ended {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 180px;
+}
+
+.duration {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js
new file mode 100644
index 000000000..4aa6d76d6
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js
@@ -0,0 +1,265 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './QueuedTaskRow.css';
+
+function getStatusIconProps(status, message) {
+ const title = titleCase(status);
+
+ switch (status) {
+ case 'queued':
+ return {
+ name: icons.PENDING,
+ title
+ };
+
+ case 'started':
+ return {
+ name: icons.REFRESH,
+ isSpinning: true,
+ title
+ };
+
+ case 'completed':
+ return {
+ name: icons.CHECK,
+ kind: kinds.SUCCESS,
+ title: message === 'Completed' ? title : `${title}: ${message}`
+ };
+
+ case 'failed':
+ return {
+ name: icons.FATAL,
+ kind: kinds.ERROR,
+ title: `${title}: ${message}`
+ };
+
+ default:
+ return {
+ name: icons.UNKNOWN,
+ title
+ };
+ }
+}
+
+function getFormattedDates(props) {
+ const {
+ queued,
+ started,
+ ended,
+ showRelativeDates,
+ shortDateFormat
+ } = props;
+
+ if (showRelativeDates) {
+ return {
+ queuedAt: moment(queued).fromNow(),
+ startedAt: started ? moment(started).fromNow() : '-',
+ endedAt: ended ? moment(ended).fromNow() : '-'
+ };
+ }
+
+ return {
+ queuedAt: formatDate(queued, shortDateFormat),
+ startedAt: started ? formatDate(started, shortDateFormat) : '-',
+ endedAt: ended ? formatDate(ended, shortDateFormat) : '-'
+ };
+}
+
+class QueuedTaskRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ ...getFormattedDates(props),
+ isCancelConfirmModalOpen: false
+ };
+
+ this._updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ this.setUpdateTimer();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ queued,
+ started,
+ ended
+ } = this.props;
+
+ if (
+ queued !== prevProps.queued ||
+ started !== prevProps.started ||
+ ended !== prevProps.ended
+ ) {
+ this.setState(getFormattedDates(this.props));
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._updateTimeoutId) {
+ this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
+ }
+ }
+
+ //
+ // Control
+
+ setUpdateTimer() {
+ this._updateTimeoutId = setTimeout(() => {
+ this.setState(getFormattedDates(this.props));
+ this.setUpdateTimer();
+ }, 30000);
+ }
+
+ //
+ // Listeners
+
+ onCancelPress = () => {
+ this.setState({
+ isCancelConfirmModalOpen: true
+ });
+ }
+
+ onAbortCancel = () => {
+ this.setState({
+ isCancelConfirmModalOpen: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ trigger,
+ commandName,
+ queued,
+ started,
+ ended,
+ status,
+ duration,
+ message,
+ longDateFormat,
+ timeFormat,
+ onCancelPress
+ } = this.props;
+
+ const {
+ queuedAt,
+ startedAt,
+ endedAt,
+ isCancelConfirmModalOpen
+ } = this.state;
+
+ let triggerIcon = icons.UNKNOWN;
+
+ if (trigger === 'manual') {
+ triggerIcon = icons.INTERACTIVE;
+ } else if (trigger === 'scheduled') {
+ triggerIcon = icons.SCHEDULED;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {commandName}
+
+
+ {queuedAt}
+
+
+
+ {startedAt}
+
+
+
+ {endedAt}
+
+
+
+ {formatTimeSpan(duration)}
+
+
+
+ {
+ status === 'queued' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+QueuedTaskRow.propTypes = {
+ trigger: PropTypes.string.isRequired,
+ commandName: PropTypes.string.isRequired,
+ queued: PropTypes.string.isRequired,
+ started: PropTypes.string,
+ ended: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ duration: PropTypes.string,
+ message: PropTypes.string,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onCancelPress: PropTypes.func.isRequired
+};
+
+export default QueuedTaskRow;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js
new file mode 100644
index 000000000..f55ab985a
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js
@@ -0,0 +1,31 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { cancelCommand } from 'Store/Actions/commandActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import QueuedTaskRow from './QueuedTaskRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return {
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onCancelPress() {
+ dispatch(cancelCommand({
+ id: props.id
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow);
diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js
new file mode 100644
index 000000000..a2fd526fa
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTasks.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import QueuedTaskRowConnector from './QueuedTaskRowConnector';
+
+const columns = [
+ {
+ name: 'trigger',
+ label: '',
+ isVisible: true
+ },
+ {
+ name: 'commandName',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'queued',
+ label: 'Queued',
+ isVisible: true
+ },
+ {
+ name: 'started',
+ label: 'Started',
+ isVisible: true
+ },
+ {
+ name: 'ended',
+ label: 'Ended',
+ isVisible: true
+ },
+ {
+ name: 'duration',
+ label: 'Duration',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+function QueuedTasks(props) {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+}
+
+QueuedTasks.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default QueuedTasks;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js
new file mode 100644
index 000000000..5fa4d9ead
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchCommands } from 'Store/Actions/commandActions';
+import QueuedTasks from './QueuedTasks';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.commands,
+ (commands) => {
+ return commands;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchCommands: fetchCommands
+};
+
+class QueuedTasksConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchCommands();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueuedTasksConnector.propTypes = {
+ dispatchFetchCommands: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector);
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css
new file mode 100644
index 000000000..dc83cfd69
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css
@@ -0,0 +1,18 @@
+.interval {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.lastExecution,
+.nextExecution {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 180px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js
new file mode 100644
index 000000000..82cedc720
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js
@@ -0,0 +1,182 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import { icons } from 'Helpers/Props';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './ScheduledTaskRow.css';
+
+function getFormattedDates(props) {
+ const {
+ lastExecution,
+ nextExecution,
+ interval,
+ showRelativeDates,
+ shortDateFormat
+ } = props;
+
+ const isDisabled = interval === 0;
+
+ if (showRelativeDates) {
+ return {
+ lastExecutionTime: moment(lastExecution).fromNow(),
+ nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow()
+ };
+ }
+
+ return {
+ lastExecutionTime: formatDate(lastExecution, shortDateFormat),
+ nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat)
+ };
+}
+
+class ScheduledTaskRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = getFormattedDates(props);
+
+ this._updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ this.setUpdateTimer();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ lastExecution,
+ nextExecution
+ } = this.props;
+
+ if (
+ lastExecution !== prevProps.lastExecution ||
+ nextExecution !== prevProps.nextExecution
+ ) {
+ this.setState(getFormattedDates(this.props));
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._updateTimeoutId) {
+ this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ setUpdateTimer() {
+ const { interval } = this.props;
+ const timeout = interval < 60 ? 10000 : 60000;
+
+ this._updateTimeoutId = setTimeout(() => {
+ this.setState(getFormattedDates(this.props));
+ this.setUpdateTimer();
+ }, timeout);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ interval,
+ lastExecution,
+ nextExecution,
+ isQueued,
+ isExecuting,
+ longDateFormat,
+ timeFormat,
+ onExecutePress
+ } = this.props;
+
+ const {
+ lastExecutionTime,
+ nextExecutionTime
+ } = this.state;
+
+ const isDisabled = interval === 0;
+ const executeNow = !isDisabled && moment().isAfter(nextExecution);
+ const hasNextExecutionTime = !isDisabled && !executeNow;
+ const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1');
+
+ return (
+
+ {name}
+
+ {isDisabled ? 'disabled' : duration}
+
+
+
+ {lastExecutionTime}
+
+
+ {
+ isDisabled &&
+ -
+ }
+
+ {
+ executeNow && isQueued &&
+ queued
+ }
+
+ {
+ executeNow && !isQueued &&
+ now
+ }
+
+ {
+ hasNextExecutionTime &&
+
+ {nextExecutionTime}
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ScheduledTaskRow.propTypes = {
+ name: PropTypes.string.isRequired,
+ interval: PropTypes.number.isRequired,
+ lastExecution: PropTypes.string.isRequired,
+ nextExecution: PropTypes.string.isRequired,
+ isQueued: PropTypes.bool.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onExecutePress: PropTypes.func.isRequired
+};
+
+export default ScheduledTaskRow;
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js
new file mode 100644
index 000000000..79a0c6c87
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand, isCommandExecuting } from 'Utilities/Command';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchTask } from 'Store/Actions/systemActions';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import ScheduledTaskRow from './ScheduledTaskRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { taskName }) => taskName,
+ createCommandsSelector(),
+ createUISettingsSelector(),
+ (taskName, commands, uiSettings) => {
+ const command = findCommand(commands, { name: taskName });
+
+ return {
+ isQueued: !!(command && command.state === 'queued'),
+ isExecuting: isCommandExecuting(command),
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ const taskName = props.taskName;
+
+ return {
+ dispatchFetchTask() {
+ dispatch(fetchTask({
+ id: props.id
+ }));
+ },
+
+ onExecutePress() {
+ dispatch(executeCommand({
+ name: taskName
+ }));
+ }
+ };
+}
+
+class ScheduledTaskRowConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ isExecuting,
+ dispatchFetchTask
+ } = this.props;
+
+ if (!isExecuting && prevProps.isExecuting) {
+ // Give the host a moment to update after the command completes
+ setTimeout(() => {
+ dispatchFetchTask();
+ }, 1000);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchTask,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+ScheduledTaskRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ dispatchFetchTask: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector);
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js
new file mode 100644
index 000000000..7c6fe8a32
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import ScheduledTaskRowConnector from './ScheduledTaskRowConnector';
+
+const columns = [
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'interval',
+ label: 'Interval',
+ isVisible: true
+ },
+ {
+ name: 'lastExecution',
+ label: 'Last Execution',
+ isVisible: true
+ },
+ {
+ name: 'nextExecution',
+ label: 'Next Execution',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+function ScheduledTasks(props) {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+}
+
+ScheduledTasks.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default ScheduledTasks;
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js
new file mode 100644
index 000000000..8f418d3bb
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchTasks } from 'Store/Actions/systemActions';
+import ScheduledTasks from './ScheduledTasks';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.tasks,
+ (tasks) => {
+ return tasks;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchTasks: fetchTasks
+};
+
+class ScheduledTasksConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchTasks();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ScheduledTasksConnector.propTypes = {
+ dispatchFetchTasks: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector);
diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js
new file mode 100644
index 000000000..dbbb4d1bf
--- /dev/null
+++ b/frontend/src/System/Tasks/Tasks.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
+import QueuedTasksConnector from './Queued/QueuedTasksConnector';
+
+function Tasks() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default Tasks;
diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css
new file mode 100644
index 000000000..d21897373
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.css
@@ -0,0 +1,4 @@
+.title {
+ margin-top: 10px;
+ font-size: 16px;
+}
diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js
new file mode 100644
index 000000000..63c7e0d85
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './UpdateChanges.css';
+
+class UpdateChanges extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ changes
+ } = this.props;
+
+ if (changes.length === 0) {
+ return null;
+ }
+
+ return (
+
+
{title}
+
+ {
+ changes.map((change, index) => {
+ return (
+
+ {change}
+
+ );
+ })
+ }
+
+
+ );
+ }
+
+}
+
+UpdateChanges.propTypes = {
+ title: PropTypes.string.isRequired,
+ changes: PropTypes.arrayOf(PropTypes.string)
+};
+
+export default UpdateChanges;
diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css
new file mode 100644
index 000000000..3502f6d1f
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.css
@@ -0,0 +1,57 @@
+.updateAvailable {
+ display: flex;
+}
+
+.upToDate {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+.upToDateIcon {
+ color: #37bc9b;
+ font-size: 30px;
+}
+
+.upToDateMessage {
+ padding-left: 5px;
+ font-size: 18px;
+ line-height: 30px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 5px;
+ margin-left: auto;
+}
+
+.update {
+ margin-top: 20px;
+}
+
+.info {
+ display: flex;
+ align-items: center;
+ margin-bottom: 10px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+}
+
+.version {
+ font-size: 21px;
+}
+
+.space {
+ padding: 0 5px;
+}
+
+.date {
+ font-size: 16px;
+}
+
+.branch {
+ composes: label from 'Components/Label.css';
+
+ margin-left: 10px;
+ font-size: 14px;
+}
diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js
new file mode 100644
index 000000000..1ee7af090
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.js
@@ -0,0 +1,169 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import formatDate from 'Utilities/Date/formatDate';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import UpdateChanges from './UpdateChanges';
+import styles from './Updates.css';
+
+class Updates extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ shortDateFormat,
+ onInstallLatestPress
+ } = this.props;
+
+ const hasUpdates = isPopulated && !error && items.length > 0;
+ const noUpdates = isPopulated && !error && !items.length;
+ const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true });
+ const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
+
+ return (
+
+
+ {
+ !isPopulated && !error &&
+
+ }
+
+ {
+ noUpdates &&
+ No updates are available
+ }
+
+ {
+ hasUpdateToInstall &&
+
+
+ Install Latest
+
+
+ {
+ isFetching &&
+
+ }
+
+ }
+
+ {
+ noUpdateToInstall &&
+
+
+
+ The latest version of Sonarr is already installed
+
+
+ {
+ isFetching &&
+
+ }
+
+ }
+
+ {
+ hasUpdates &&
+
+ {
+ items.map((update) => {
+ const hasChanges = !!update.changes;
+
+ return (
+
+
+
{update.version}
+
—
+
{formatDate(update.releaseDate, shortDateFormat)}
+
+ {
+ update.branch !== 'master' &&
+
+ {update.branch}
+
+ }
+
+
+ {
+ !hasChanges &&
+
Maintenance release
+ }
+
+ {
+ hasChanges &&
+
+
+
+
+
+ }
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!error &&
+
+ Failed to fetch updates
+
+ }
+
+
+ );
+ }
+
+}
+
+Updates.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ isInstallingUpdate: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ onInstallLatestPress: PropTypes.func.isRequired
+};
+
+export default Updates;
diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js
new file mode 100644
index 000000000..0d0aa491f
--- /dev/null
+++ b/frontend/src/System/Updates/UpdatesConnector.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import * as commandNames from 'Commands/commandNames';
+import Updates from './Updates';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.updates,
+ createUISettingsSelector(),
+ createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
+ (updates, uiSettings, isInstallingUpdate) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = updates;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ shortDateFormat: uiSettings.shortDateFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchUpdates,
+ executeCommand
+};
+
+class UpdatesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUpdates();
+ }
+
+ //
+ // Listeners
+
+ onInstallLatestPress = () => {
+ this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UpdatesConnector.propTypes = {
+ fetchUpdates: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector);
diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js
new file mode 100644
index 000000000..165bb5cc1
--- /dev/null
+++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js
@@ -0,0 +1,13 @@
+import _ from 'lodash';
+
+export default function getIndexOfFirstCharacter(items, character) {
+ return _.findIndex(items, (item) => {
+ const firstCharacter = item.sortTitle.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+}
diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js
new file mode 100644
index 000000000..1956d3bac
--- /dev/null
+++ b/frontend/src/Utilities/Array/sortByName.js
@@ -0,0 +1,5 @@
+function sortByName(a, b) {
+ return a.name.localeCompare(b.name);
+}
+
+export default sortByName;
diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js
new file mode 100644
index 000000000..cf7d5444a
--- /dev/null
+++ b/frontend/src/Utilities/Command/findCommand.js
@@ -0,0 +1,10 @@
+import _ from 'lodash';
+import isSameCommand from './isSameCommand';
+
+function findCommand(commands, options) {
+ return _.findLast(commands, (command) => {
+ return isSameCommand(command.body, options);
+ });
+}
+
+export default findCommand;
diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js
new file mode 100644
index 000000000..66043bf03
--- /dev/null
+++ b/frontend/src/Utilities/Command/index.js
@@ -0,0 +1,5 @@
+export { default as findCommand } from './findCommand';
+export { default as isCommandComplete } from './isCommandComplete';
+export { default as isCommandExecuting } from './isCommandExecuting';
+export { default as isCommandFailed } from './isCommandFailed';
+export { default as isSameCommand } from './isSameCommand';
diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js
new file mode 100644
index 000000000..558ab801b
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandComplete.js
@@ -0,0 +1,9 @@
+function isCommandComplete(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'complete';
+}
+
+export default isCommandComplete;
diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js
new file mode 100644
index 000000000..8e637704e
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandExecuting.js
@@ -0,0 +1,9 @@
+function isCommandExecuting(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'queued' || command.status === 'started';
+}
+
+export default isCommandExecuting;
diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js
new file mode 100644
index 000000000..00e5ccdf2
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandFailed.js
@@ -0,0 +1,12 @@
+function isCommandFailed(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'failed' ||
+ command.status === 'aborted' ||
+ command.status === 'cancelled' ||
+ command.status === 'orphaned';
+}
+
+export default isCommandFailed;
diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js
new file mode 100644
index 000000000..d0acb24b5
--- /dev/null
+++ b/frontend/src/Utilities/Command/isSameCommand.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+
+function isSameCommand(commandA, commandB) {
+ if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) {
+ return false;
+ }
+
+ for (const key in commandB) {
+ if (key !== 'name') {
+ const value = commandB[key];
+ if (Array.isArray(value)) {
+ if (_.difference(value, commandA[key]).length > 0) {
+ return false;
+ }
+ } else if (value !== commandA[key]) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+export default isSameCommand;
diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js
new file mode 100644
index 000000000..9285b10fe
--- /dev/null
+++ b/frontend/src/Utilities/Constants/keyCodes.js
@@ -0,0 +1,7 @@
+export const TAB = 9;
+export const ENTER = 13;
+export const SHIFT = 16;
+export const CONTROL = 17;
+export const ESCAPE = 27;
+export const UP_ARROW = 38;
+export const DOWN_ARROW = 40;
diff --git a/frontend/src/Utilities/Date/dateFilterPredicate.js b/frontend/src/Utilities/Date/dateFilterPredicate.js
new file mode 100644
index 000000000..2c74f435a
--- /dev/null
+++ b/frontend/src/Utilities/Date/dateFilterPredicate.js
@@ -0,0 +1,33 @@
+import moment from 'moment';
+import isAfter from 'Utilities/Date/isAfter';
+import isBefore from 'Utilities/Date/isBefore';
+import * as filterTypes from 'Helpers/Props/filterTypes';
+
+export default function(itemValue, filterValue, type) {
+ if (!itemValue) {
+ return false;
+ }
+
+ switch (type) {
+ case filterTypes.LESS_THAN:
+ return moment(itemValue).isBefore(filterValue);
+
+ case filterTypes.GREATER_THAN:
+ return moment(itemValue).isAfter(filterValue);
+
+ case filterTypes.IN_LAST:
+ return (
+ isAfter(itemValue, { [filterValue.time]: filterValue.value * -1 }) &&
+ isBefore(itemValue)
+ );
+
+ case filterTypes.IN_NEXT:
+ return (
+ isAfter(itemValue) &&
+ isBefore(itemValue, { [filterValue.time]: filterValue.value })
+ );
+
+ default:
+ return false;
+ }
+}
diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js
new file mode 100644
index 000000000..92eb57840
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDate.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function formatDate(date, dateFormat) {
+ if (!date) {
+ return '';
+ }
+
+ return moment(date).format(dateFormat);
+}
+
+export default formatDate;
diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js
new file mode 100644
index 000000000..f36f4f3e0
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDateTime.js
@@ -0,0 +1,39 @@
+import moment from 'moment';
+import formatTime from './formatTime';
+import isToday from './isToday';
+import isTomorrow from './isTomorrow';
+import isYesterday from './isYesterday';
+
+function getRelativeDay(date, includeRelativeDate) {
+ if (!includeRelativeDate) {
+ return '';
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday, ';
+ }
+
+ if (isToday(date)) {
+ return 'Today, ';
+ }
+
+ if (isTomorrow(date)) {
+ return 'Tomorrow, ';
+ }
+
+ return '';
+}
+
+function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ const relativeDay = getRelativeDay(date, includeRelativeDay);
+ const formattedDate = moment(date).format(dateFormat);
+ const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+
+ return `${relativeDay}${formattedDate} ${formattedTime}`;
+}
+
+export default formatDateTime;
diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js
new file mode 100644
index 000000000..89c908d1f
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTime.js
@@ -0,0 +1,19 @@
+import moment from 'moment';
+
+function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ if (includeSeconds) {
+ timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss');
+ } else if (includeMinuteZero) {
+ timeFormat = timeFormat.replace('(:mm)', ':mm');
+ } else {
+ timeFormat = timeFormat.replace('(:mm)', '');
+ }
+
+ return moment(date).format(timeFormat);
+}
+
+export default formatTime;
diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js
new file mode 100644
index 000000000..ef1a278e5
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTimeSpan.js
@@ -0,0 +1,24 @@
+import moment from 'moment';
+import padNumber from 'Utilities/Number/padNumber';
+
+function formatTimeSpan(timeSpan) {
+ if (!timeSpan) {
+ return '';
+ }
+
+ const duration = moment.duration(timeSpan);
+ const days = duration.get('days');
+ const hours = padNumber(duration.get('hours'), 2);
+ const minutes = padNumber(duration.get('minutes'), 2);
+ const seconds = padNumber(duration.get('seconds'), 2);
+
+ const time = `${hours}:${minutes}:${seconds}`;
+
+ if (days > 0) {
+ return `${days}d ${time}`;
+ }
+
+ return time;
+}
+
+export default formatTimeSpan;
diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.js
new file mode 100644
index 000000000..0a60135ce
--- /dev/null
+++ b/frontend/src/Utilities/Date/getRelativeDate.js
@@ -0,0 +1,42 @@
+import moment from 'moment';
+import formatTime from 'Utilities/Date/formatTime';
+import isInNextWeek from 'Utilities/Date/isInNextWeek';
+import isToday from 'Utilities/Date/isToday';
+import isTomorrow from 'Utilities/Date/isTomorrow';
+import isYesterday from 'Utilities/Date/isYesterday';
+
+function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) {
+ if (!date) {
+ return null;
+ }
+
+ const isTodayDate = isToday(date);
+
+ if (isTodayDate && timeForToday && timeFormat) {
+ return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+ }
+
+ if (!showRelativeDates) {
+ return moment(date).format(shortDateFormat);
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday';
+ }
+
+ if (isTodayDate) {
+ return 'Today';
+ }
+
+ if (isTomorrow(date)) {
+ return 'Tomorrow';
+ }
+
+ if (isInNextWeek(date)) {
+ return moment(date).format('dddd');
+ }
+
+ return moment(date).format(shortDateFormat);
+}
+
+export default getRelativeDate;
diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js
new file mode 100644
index 000000000..4bbd8660b
--- /dev/null
+++ b/frontend/src/Utilities/Date/isAfter.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isAfter(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isAfter(offsetTime);
+}
+
+export default isAfter;
diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js
new file mode 100644
index 000000000..3e1e81f67
--- /dev/null
+++ b/frontend/src/Utilities/Date/isBefore.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isBefore(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isBefore(offsetTime);
+}
+
+export default isBefore;
diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js
new file mode 100644
index 000000000..7b5fd7cc7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isInNextWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isInNextWeek(date) {
+ if (!date) {
+ return false;
+ }
+ const now = moment();
+ return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day'));
+}
+
+export default isInNextWeek;
diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js
new file mode 100644
index 000000000..14b76ffb7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isSameWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isSameWeek(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'week');
+}
+
+export default isSameWeek;
diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js
new file mode 100644
index 000000000..31502951f
--- /dev/null
+++ b/frontend/src/Utilities/Date/isToday.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isToday(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'day');
+}
+
+export default isToday;
diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js
new file mode 100644
index 000000000..d22386dbd
--- /dev/null
+++ b/frontend/src/Utilities/Date/isTomorrow.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isTomorrow(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment().add(1, 'day'), 'day');
+}
+
+export default isTomorrow;
diff --git a/frontend/src/Utilities/Date/isYesterday.js b/frontend/src/Utilities/Date/isYesterday.js
new file mode 100644
index 000000000..9de21d82a
--- /dev/null
+++ b/frontend/src/Utilities/Date/isYesterday.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isYesterday(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment().subtract(1, 'day'), 'day');
+}
+
+export default isYesterday;
diff --git a/frontend/src/Utilities/Episode/updateEpisodes.js b/frontend/src/Utilities/Episode/updateEpisodes.js
new file mode 100644
index 000000000..80890b53f
--- /dev/null
+++ b/frontend/src/Utilities/Episode/updateEpisodes.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { update } from 'Store/Actions/baseActions';
+
+function updateEpisodes(section, episodes, episodeIds, options) {
+ const data = _.reduce(episodes, (result, item) => {
+ if (episodeIds.indexOf(item.id) > -1) {
+ result.push({
+ ...item,
+ ...options
+ });
+ } else {
+ result.push(item);
+ }
+
+ return result;
+ }, []);
+
+ return update({ section, data });
+}
+
+export default updateEpisodes;
diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js
new file mode 100644
index 000000000..1c104073c
--- /dev/null
+++ b/frontend/src/Utilities/Filter/findSelectedFilters.js
@@ -0,0 +1,19 @@
+export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) {
+ if (!selectedFilterKey) {
+ return [];
+ }
+
+ let selectedFilter = filters.find((f) => f.key === selectedFilterKey);
+
+ if (!selectedFilter) {
+ selectedFilter = customFilters.find((f) => f.id === selectedFilterKey);
+ }
+
+ if (!selectedFilter) {
+ // TODO: throw in dev
+ console.error('Matching filter not found');
+ return [];
+ }
+
+ return selectedFilter.filters;
+}
diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js
new file mode 100644
index 000000000..70b0b51f1
--- /dev/null
+++ b/frontend/src/Utilities/Filter/getFilterValue.js
@@ -0,0 +1,11 @@
+export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) {
+ const filter = filters.find((f) => f.key === filterKey);
+
+ if (!filter) {
+ return defaultValue;
+ }
+
+ const filterValue = filter.filters.find((f) => f.key === filterValueKey);
+
+ return filterValue ? filterValue.value : defaultValue;
+}
diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js
new file mode 100644
index 000000000..6c63fb117
--- /dev/null
+++ b/frontend/src/Utilities/Number/convertToBytes.js
@@ -0,0 +1,16 @@
+
+function convertToBytes(input, power, binaryPrefix) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ const prefix = binaryPrefix ? 1024 : 1000;
+ const multiplier = Math.pow(prefix, power);
+ const result = size * multiplier;
+
+ return Math.round(result);
+}
+
+export default convertToBytes;
diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js
new file mode 100644
index 000000000..b8a4aacc5
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatAge.js
@@ -0,0 +1,17 @@
+function formatAge(age, ageHours, ageMinutes) {
+ age = Math.round(age);
+ ageHours = parseFloat(ageHours);
+ ageMinutes = ageMinutes && parseFloat(ageMinutes);
+
+ if (age < 2 && ageHours) {
+ if (ageHours < 2 && !!ageMinutes) {
+ return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`;
+ }
+
+ return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`;
+ }
+
+ return `${age} ${age === 1 ? 'day' : 'days'}`;
+}
+
+export default formatAge;
diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js
new file mode 100644
index 000000000..1ff1b5a97
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatBytes.js
@@ -0,0 +1,16 @@
+import filesize from 'filesize';
+
+function formatBytes(input) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ return filesize(size, {
+ base: 2,
+ round: 1
+ });
+}
+
+export default formatBytes;
diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js
new file mode 100644
index 000000000..53ae69cac
--- /dev/null
+++ b/frontend/src/Utilities/Number/padNumber.js
@@ -0,0 +1,10 @@
+function padNumber(input, width, paddingCharacter = 0) {
+ if (input == null) {
+ return '';
+ }
+
+ input = `${input}`;
+ return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input;
+}
+
+export default padNumber;
diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js
new file mode 100644
index 000000000..1ba874660
--- /dev/null
+++ b/frontend/src/Utilities/Object/getErrorMessage.js
@@ -0,0 +1,11 @@
+function getErrorMessage(xhr, fallbackErrorMessage) {
+ if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) {
+ return fallbackErrorMessage;
+ }
+
+ const message = xhr.responseJSON.message;
+
+ return message || fallbackErrorMessage;
+}
+
+export default getErrorMessage;
diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js
new file mode 100644
index 000000000..f89c99a10
--- /dev/null
+++ b/frontend/src/Utilities/Object/hasDifferentItems.js
@@ -0,0 +1,10 @@
+import _ from 'lodash';
+
+function hasDifferentItems(prevItems, currentItems, idProp = 'id') {
+ const diff1 = _.differenceBy(prevItems, currentItems, (item) => item[idProp]);
+ const diff2 = _.differenceBy(currentItems, prevItems, (item) => item[idProp]);
+
+ return diff1.length > 0 || diff2.length > 0;
+}
+
+export default hasDifferentItems;
diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js
new file mode 100644
index 000000000..c2c0c17e3
--- /dev/null
+++ b/frontend/src/Utilities/Object/selectUniqueIds.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+
+function selectUniqueIds(items, idProp) {
+ const ids = _.reduce(items, (result, item) => {
+ if (item[idProp]) {
+ result.push(item[idProp]);
+ }
+
+ return result;
+ }, []);
+
+ return _.uniq(ids);
+}
+
+export default selectUniqueIds;
diff --git a/frontend/src/Utilities/Quality/getQualities.js b/frontend/src/Utilities/Quality/getQualities.js
new file mode 100644
index 000000000..da09851ea
--- /dev/null
+++ b/frontend/src/Utilities/Quality/getQualities.js
@@ -0,0 +1,16 @@
+export default function getQualities(qualities) {
+ if (!qualities) {
+ return [];
+ }
+
+ return qualities.reduce((acc, item) => {
+ if (item.quality) {
+ acc.push(item.quality);
+ } else {
+ const groupQualities = item.items.map((i) => i.quality);
+ acc.push(...groupQualities);
+ }
+
+ return acc;
+ }, []);
+}
diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js
new file mode 100644
index 000000000..358448ca9
--- /dev/null
+++ b/frontend/src/Utilities/ResolutionUtility.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+
+module.exports = {
+ resolutions: {
+ desktopLarge: 1200,
+ desktop: 992,
+ tablet: 768,
+ mobile: 480
+ },
+
+ isDesktopLarge() {
+ return $(window).width() < this.resolutions.desktopLarge;
+ },
+
+ isDesktop() {
+ return $(window).width() < this.resolutions.desktop;
+ },
+
+ isTablet() {
+ return $(window).width() < this.resolutions.tablet;
+ },
+
+ isMobile() {
+ return $(window).width() < this.resolutions.mobile;
+ }
+};
diff --git a/frontend/src/Utilities/Series/getNewSeries.js b/frontend/src/Utilities/Series/getNewSeries.js
new file mode 100644
index 000000000..f33b5c869
--- /dev/null
+++ b/frontend/src/Utilities/Series/getNewSeries.js
@@ -0,0 +1,31 @@
+
+function getNewSeries(series, payload) {
+ const {
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder,
+ tags,
+ searchForMissingEpisodes = false
+ } = payload;
+
+ const addOptions = {
+ monitor,
+ searchForMissingEpisodes
+ };
+
+ series.addOptions = addOptions;
+ series.monitored = true;
+ series.qualityProfileId = qualityProfileId;
+ series.languageProfileId = languageProfileId;
+ series.rootFolderPath = rootFolderPath;
+ series.seriesType = seriesType;
+ series.seasonFolder = seasonFolder;
+ series.tags = tags;
+
+ return series;
+}
+
+export default getNewSeries;
diff --git a/frontend/src/Utilities/Series/getProgressBarKind.js b/frontend/src/Utilities/Series/getProgressBarKind.js
new file mode 100644
index 000000000..eb3b2dd6e
--- /dev/null
+++ b/frontend/src/Utilities/Series/getProgressBarKind.js
@@ -0,0 +1,15 @@
+import { kinds } from 'Helpers/Props';
+
+function getProgressBarKind(status, monitored, progress) {
+ if (progress === 100) {
+ return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY;
+ }
+
+ if (monitored) {
+ return kinds.DANGER;
+ }
+
+ return kinds.WARNING;
+}
+
+export default getProgressBarKind;
diff --git a/frontend/src/Utilities/Series/monitorOptions.js b/frontend/src/Utilities/Series/monitorOptions.js
new file mode 100644
index 000000000..57d46413f
--- /dev/null
+++ b/frontend/src/Utilities/Series/monitorOptions.js
@@ -0,0 +1,11 @@
+const monitorOptions = [
+ { key: 'all', value: 'All Episodes' },
+ { key: 'future', value: 'Future Episodes' },
+ { key: 'missing', value: 'Missing Episodes' },
+ { key: 'existing', value: 'Existing Episodes' },
+ { key: 'firstSeason', value: 'Only First Season' },
+ { key: 'latestSeason', value: 'Only Latest Season' },
+ { key: 'none', value: 'None' }
+];
+
+export default monitorOptions;
diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js
new file mode 100644
index 000000000..987680cb5
--- /dev/null
+++ b/frontend/src/Utilities/State/getProviderState.js
@@ -0,0 +1,35 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function getProviderState(payload, getState, section) {
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const state = getSectionState(getState(), section, true);
+ const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload);
+ const pendingFields = state.pendingChanges.fields || {};
+ delete pendingChanges.fields;
+
+ const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {};
+
+ if (item.fields) {
+ pendingChanges.fields = _.reduce(item.fields, (result, field) => {
+ const value = pendingFields.hasOwnProperty(field.name) ?
+ pendingFields[field.name] :
+ field.value;
+
+ result.push({
+ ...field,
+ value
+ });
+
+ return result;
+ }, []);
+ }
+
+ return Object.assign({}, item, pendingChanges);
+}
+
+export default getProviderState;
diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js
new file mode 100644
index 000000000..00871bed2
--- /dev/null
+++ b/frontend/src/Utilities/State/getSectionState.js
@@ -0,0 +1,22 @@
+import _ from 'lodash';
+
+function getSectionState(state, section, isFullStateTree = false) {
+ if (isFullStateTree) {
+ return _.get(state, section);
+ }
+
+ const [, subSection] = section.split('.');
+
+ if (subSection) {
+ return Object.assign({}, state[subSection]);
+ }
+
+ // TODO: Remove in favour of using subSection
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state[section]);
+ }
+
+ return Object.assign({}, state);
+}
+
+export default getSectionState;
diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js
new file mode 100644
index 000000000..c8a31760c
--- /dev/null
+++ b/frontend/src/Utilities/State/selectProviderSchema.js
@@ -0,0 +1,34 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function applySchemaDefaults(selectedSchema, schemaDefaults) {
+ if (!schemaDefaults) {
+ return selectedSchema;
+ } else if (_.isFunction(schemaDefaults)) {
+ return schemaDefaults(selectedSchema);
+ }
+
+ return Object.assign(selectedSchema, schemaDefaults);
+}
+
+function selectProviderSchema(state, section, payload, schemaDefaults) {
+ const newState = getSectionState(state, section);
+
+ const {
+ implementation,
+ presetName
+ } = payload;
+
+ const selectedImplementation = _.find(newState.schema, { implementation });
+
+ const selectedSchema = presetName ?
+ _.find(selectedImplementation.presets, { name: presetName }) :
+ selectedImplementation;
+
+ newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults);
+
+ return updateSectionState(state, section, newState);
+}
+
+export default selectProviderSchema;
diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js
new file mode 100644
index 000000000..81b33ecaf
--- /dev/null
+++ b/frontend/src/Utilities/State/updateSectionState.js
@@ -0,0 +1,16 @@
+function updateSectionState(state, section, newState) {
+ const [, subSection] = section.split('.');
+
+ if (subSection) {
+ return Object.assign({}, state, { [subSection]: newState });
+ }
+
+ // TODO: Remove in favour of using subSection
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state, { [section]: newState });
+ }
+
+ return Object.assign({}, state, newState);
+}
+
+export default updateSectionState;
diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js
new file mode 100644
index 000000000..9e4e9abe8
--- /dev/null
+++ b/frontend/src/Utilities/String/combinePath.js
@@ -0,0 +1,5 @@
+export default function combinePath(isWindows, basePath, paths = []) {
+ const slash = isWindows ? '\\' : '/';
+
+ return `${basePath}${slash}${paths.join(slash)}`;
+}
diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js
new file mode 100644
index 000000000..51b15ec60
--- /dev/null
+++ b/frontend/src/Utilities/String/generateUUIDv4.js
@@ -0,0 +1,6 @@
+export default function generateUUIDv4() {
+ return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
+ // eslint-disable-next-line no-bitwise
+ (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
+ );
+}
diff --git a/frontend/src/Utilities/String/isString.js b/frontend/src/Utilities/String/isString.js
new file mode 100644
index 000000000..1e7c3dff8
--- /dev/null
+++ b/frontend/src/Utilities/String/isString.js
@@ -0,0 +1,3 @@
+export default function isString(possibleString) {
+ return typeof possibleString === 'string' || possibleString instanceof String;
+}
diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js
new file mode 100644
index 000000000..93341f85f
--- /dev/null
+++ b/frontend/src/Utilities/String/parseUrl.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import qs from 'qs';
+
+// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils
+const anchor = document.createElement('a');
+
+export default function parseUrl(url) {
+ anchor.href = url;
+
+ // The `origin`, `password`, and `username` properties are unavailable in
+ // Opera Presto. We synthesize `origin` if it's not present. While `password`
+ // and `username` are ignored intentionally.
+ const properties = _.pick(
+ anchor,
+ 'hash',
+ 'host',
+ 'hostname',
+ 'href',
+ 'origin',
+ 'pathname',
+ 'port',
+ 'protocol',
+ 'search'
+ );
+
+ properties.isAbsolute = (/^[\w:]*\/\//).test(url);
+
+ if (properties.search) {
+ // Remove leading ? from querystring before parsing.
+ properties.params = qs.parse(properties.search.substring(1));
+ } else {
+ properties.params = {};
+ }
+
+ return properties;
+}
diff --git a/frontend/src/Utilities/String/split.js b/frontend/src/Utilities/String/split.js
new file mode 100644
index 000000000..0e57e7545
--- /dev/null
+++ b/frontend/src/Utilities/String/split.js
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+function split(input, separator = ',') {
+ if (!input) {
+ return [];
+ }
+
+ return _.reduce(input.split(separator), (result, s) => {
+ if (s) {
+ result.push(s);
+ }
+
+ return result;
+ }, []);
+}
+
+export default split;
diff --git a/frontend/src/Utilities/String/titleCase.js b/frontend/src/Utilities/String/titleCase.js
new file mode 100644
index 000000000..5b76c10dd
--- /dev/null
+++ b/frontend/src/Utilities/String/titleCase.js
@@ -0,0 +1,11 @@
+function titleCase(input) {
+ if (!input) {
+ return '';
+ }
+
+ return input.replace(/\b\w+/g, (match) => {
+ return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase();
+ });
+}
+
+export default titleCase;
diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js
new file mode 100644
index 000000000..26102f89b
--- /dev/null
+++ b/frontend/src/Utilities/Table/areAllSelected.js
@@ -0,0 +1,17 @@
+export default function areAllSelected(selectedState) {
+ let allSelected = true;
+ let allUnselected = true;
+
+ Object.keys(selectedState).forEach((key) => {
+ if (selectedState[key]) {
+ allUnselected = false;
+ } else {
+ allSelected = false;
+ }
+ });
+
+ return {
+ allSelected,
+ allUnselected
+ };
+}
diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js
new file mode 100644
index 000000000..705f13a5d
--- /dev/null
+++ b/frontend/src/Utilities/Table/getSelectedIds.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+
+function getSelectedIds(selectedState, { parseIds = true } = {}) {
+ return _.reduce(selectedState, (result, value, id) => {
+ if (value) {
+ const parsedId = parseIds ? parseInt(id) : id;
+
+ result.push(parsedId);
+ }
+
+ return result;
+ }, []);
+}
+
+export default getSelectedIds;
diff --git a/frontend/src/Utilities/Table/getToggledRange.js b/frontend/src/Utilities/Table/getToggledRange.js
new file mode 100644
index 000000000..c0cc44fe5
--- /dev/null
+++ b/frontend/src/Utilities/Table/getToggledRange.js
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+
+function getToggledRange(items, id, lastToggled) {
+ const lastToggledIndex = _.findIndex(items, { id: lastToggled });
+ const changedIndex = _.findIndex(items, { id });
+ let lower = 0;
+ let upper = 0;
+
+ if (lastToggledIndex > changedIndex) {
+ lower = changedIndex;
+ upper = lastToggledIndex + 1;
+ } else {
+ lower = lastToggledIndex;
+ upper = changedIndex;
+ }
+
+ return {
+ lower,
+ upper
+ };
+}
+
+export default getToggledRange;
diff --git a/frontend/src/Utilities/Table/removeOldSelectedState.js b/frontend/src/Utilities/Table/removeOldSelectedState.js
new file mode 100644
index 000000000..ff3a4fe11
--- /dev/null
+++ b/frontend/src/Utilities/Table/removeOldSelectedState.js
@@ -0,0 +1,16 @@
+import areAllSelected from './areAllSelected';
+
+export default function removeOldSelectedState(state, prevItems) {
+ const selectedState = {
+ ...state.selectedState
+ };
+
+ prevItems.forEach((item) => {
+ delete selectedState[item.id];
+ });
+
+ return {
+ ...areAllSelected(selectedState),
+ selectedState
+ };
+}
diff --git a/frontend/src/Utilities/Table/selectAll.js b/frontend/src/Utilities/Table/selectAll.js
new file mode 100644
index 000000000..ffaaeaddf
--- /dev/null
+++ b/frontend/src/Utilities/Table/selectAll.js
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+function selectAll(selectedState, selected) {
+ const newSelectedState = _.reduce(Object.keys(selectedState), (result, item) => {
+ result[item] = selected;
+ return result;
+ }, {});
+
+ return {
+ allSelected: selected,
+ allUnselected: !selected,
+ lastToggled: null,
+ selectedState: newSelectedState
+ };
+}
+
+export default selectAll;
diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js
new file mode 100644
index 000000000..dbc0d6223
--- /dev/null
+++ b/frontend/src/Utilities/Table/toggleSelected.js
@@ -0,0 +1,30 @@
+import areAllSelected from './areAllSelected';
+import getToggledRange from './getToggledRange';
+
+function toggleSelected(state, items, id, selected, shiftKey) {
+ const lastToggled = state.lastToggled;
+ const selectedState = {
+ ...state.selectedState,
+ [id]: selected
+ };
+
+ if (selected == null) {
+ delete selectedState[id];
+ }
+
+ if (shiftKey && lastToggled) {
+ const { lower, upper } = getToggledRange(items, id, lastToggled);
+
+ for (let i = lower; i < upper; i++) {
+ selectedState[items[i].id] = selected;
+ }
+ }
+
+ return {
+ ...areAllSelected(selectedState),
+ lastToggled: id,
+ selectedState
+ };
+}
+
+export default toggleSelected;
diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js
new file mode 100644
index 000000000..7ad8961da
--- /dev/null
+++ b/frontend/src/Utilities/createAjaxRequest.js
@@ -0,0 +1,30 @@
+import $ from 'jquery';
+
+export default function createAjaxRequest(ajaxOptions) {
+ const requestXHR = new window.XMLHttpRequest();
+ let aborted = false;
+ let complete = false;
+
+ function abortRequest() {
+ if (!complete) {
+ aborted = true;
+ requestXHR.abort();
+ }
+ }
+
+ const request = $.ajax({
+ xhr: () => requestXHR,
+ ...ajaxOptions
+ }).then(null, (xhr, textStatus, errorThrown) => {
+ xhr.aborted = aborted;
+
+ return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
+ }).always(() => {
+ complete = true;
+ });
+
+ return {
+ request,
+ abortRequest
+ };
+}
diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js
new file mode 100644
index 000000000..60533d3d3
--- /dev/null
+++ b/frontend/src/Utilities/getPathWithUrlBase.js
@@ -0,0 +1,3 @@
+export default function getPathWithUrlBase(path) {
+ return `${window.Sonarr.urlBase}${path}`;
+}
diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js
new file mode 100644
index 000000000..dae5150b7
--- /dev/null
+++ b/frontend/src/Utilities/getUniqueElementId.js
@@ -0,0 +1,7 @@
+let i = 0;
+
+// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
+
+export default function getUniqueElementId() {
+ return `id-${i++}`;
+}
diff --git a/frontend/src/Utilities/isMobile.js b/frontend/src/Utilities/isMobile.js
new file mode 100644
index 000000000..489020a23
--- /dev/null
+++ b/frontend/src/Utilities/isMobile.js
@@ -0,0 +1,7 @@
+import MobileDetect from 'mobile-detect';
+
+export default function isMobile() {
+ const mobileDetect = new MobileDetect(window.navigator.userAgent);
+
+ return mobileDetect.mobile() != null;
+}
diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js
new file mode 100644
index 000000000..f58dbe803
--- /dev/null
+++ b/frontend/src/Utilities/pagePopulator.js
@@ -0,0 +1,28 @@
+let currentPopulator = null;
+let currentReasons = [];
+
+export function registerPagePopulator(populator, reasons = []) {
+ currentPopulator = populator;
+ currentReasons = reasons;
+}
+
+export function unregisterPagePopulator(populator) {
+ if (currentPopulator === populator) {
+ currentPopulator = null;
+ currentReasons = [];
+ }
+}
+
+export function repopulatePage(reason) {
+ if (!currentPopulator) {
+ return;
+ }
+
+ if (!reason) {
+ currentPopulator();
+ }
+
+ if (reason && currentReasons.includes(reason)) {
+ currentPopulator();
+ }
+}
diff --git a/frontend/src/Utilities/pages.js b/frontend/src/Utilities/pages.js
new file mode 100644
index 000000000..1355442d9
--- /dev/null
+++ b/frontend/src/Utilities/pages.js
@@ -0,0 +1,9 @@
+const pages = {
+ FIRST: 'first',
+ PREVIOUS: 'previous',
+ NEXT: 'next',
+ LAST: 'last',
+ EXACT: 'exact'
+};
+
+export default pages;
diff --git a/frontend/src/Utilities/requestAction.js b/frontend/src/Utilities/requestAction.js
new file mode 100644
index 000000000..3f2564a7b
--- /dev/null
+++ b/frontend/src/Utilities/requestAction.js
@@ -0,0 +1,40 @@
+import $ from 'jquery';
+import _ from 'lodash';
+
+function flattenProviderData(providerData) {
+ return _.reduce(Object.keys(providerData), (result, key) => {
+ const property = providerData[key];
+
+ if (key === 'fields') {
+ result[key] = property;
+ } else {
+ result[key] = property.value;
+ }
+
+ return result;
+ }, {});
+}
+
+function requestAction(payload) {
+ const {
+ provider,
+ action,
+ providerData,
+ queryParams
+ } = payload;
+
+ const ajaxOptions = {
+ url: `/${provider}/action/${action}`,
+ contentType: 'application/json',
+ method: 'POST',
+ data: JSON.stringify(flattenProviderData(providerData))
+ };
+
+ if (queryParams) {
+ ajaxOptions.url += `?${$.param(queryParams, true)}`;
+ }
+
+ return $.ajax(ajaxOptions);
+}
+
+export default requestAction;
diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js
new file mode 100644
index 000000000..5479b32b9
--- /dev/null
+++ b/frontend/src/Utilities/sectionTypes.js
@@ -0,0 +1,6 @@
+const sectionTypes = {
+ COLLECTION: 'collection',
+ MODEL: 'model'
+};
+
+export default sectionTypes;
diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js
new file mode 100644
index 000000000..03fa39c00
--- /dev/null
+++ b/frontend/src/Utilities/serverSideCollectionHandlers.js
@@ -0,0 +1,12 @@
+const serverSideCollectionHandlers = {
+ FETCH: 'fetch',
+ FIRST_PAGE: 'firstPage',
+ PREVIOUS_PAGE: 'previousPage',
+ NEXT_PAGE: 'nextPage',
+ LAST_PAGE: 'lastPage',
+ EXACT_PAGE: 'exactPage',
+ SORT: 'sort',
+ FILTER: 'filter'
+};
+
+export default serverSideCollectionHandlers;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
new file mode 100644
index 000000000..e12d3a160
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
@@ -0,0 +1,280 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import CutoffUnmetRowConnector from './CutoffUnmetRowConnector';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class CutoffUnmet extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllCutoffUnmetModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onFilterMenuItemPress = (filterKey, filterValue) => {
+ this.props.onFilterSelect(filterKey, filterValue);
+ }
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ }
+
+ onToggleSelectedPress = () => {
+ const episodeIds = this.getSelectedIds();
+
+ this.props.batchToggleCutoffUnmetEpisodes({
+ episodeIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ }
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
+ }
+
+ onSearchAllCutoffUnmetConfirmed = () => {
+ this.props.onSearchAllCutoffUnmetPress();
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ }
+
+ onConfirmSearchAllCutoffUnmetModalClose = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForCutoffUnmetEpisodes,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllCutoffUnmetModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ Error fetching cutoff unmet
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No cutoff unmet items
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Are you sure you want to search for all {totalRecords} Cutoff Unmet episodes?
+
+
+ This cannot be cancelled once started without restarting Sonarr.
+
+
+ }
+ confirmLabel="Search"
+ onConfirm={this.onSearchAllCutoffUnmetConfirmed}
+ onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
+ />
+
+ }
+
+
+ );
+ }
+}
+
+CutoffUnmet.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForCutoffUnmetEpisodes: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleCutoffUnmetEpisodes: PropTypes.func.isRequired,
+ onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
+};
+
+export default CutoffUnmet;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
new file mode 100644
index 000000000..398d1ccc5
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
@@ -0,0 +1,183 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
+import * as commandNames from 'Commands/commandNames';
+import CutoffUnmet from './CutoffUnmet';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.cutoffUnmet,
+ createCommandExecutingSelector(commandNames.CUTOFF_UNMET_EPISODE_SEARCH),
+ (cutoffUnmet, isSearchingForCutoffUnmetEpisodes) => {
+ return {
+ isSearchingForCutoffUnmetEpisodes,
+ isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
+ ...cutoffUnmet
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails,
+ fetchEpisodeFiles,
+ clearEpisodeFiles
+};
+
+class CutoffUnmetConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchCutoffUnmet,
+ gotoCutoffUnmetFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['episodeFileUpdated']);
+
+ if (useCurrentPage) {
+ fetchCutoffUnmet();
+ } else {
+ gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const episodeIds = selectUniqueIds(this.props.items, 'id');
+ const episodeFileIds = selectUniqueIds(this.props.items, 'episodeFileId');
+
+ this.props.fetchQueueDetails({ episodeIds });
+
+ if (episodeFileIds.length) {
+ this.props.fetchEpisodeFiles({ episodeFileIds });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCutoffUnmet();
+ this.props.clearQueueDetails();
+ this.props.clearEpisodeFiles();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchCutoffUnmet();
+ }
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoCutoffUnmetPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoCutoffUnmetNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoCutoffUnmetLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoCutoffUnmetPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setCutoffUnmetSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setCutoffUnmetFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setCutoffUnmetTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.EPISODE_SEARCH,
+ episodeIds: selected
+ });
+ }
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CUTOFF_UNMET_EPISODE_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CutoffUnmetConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchCutoffUnmet: PropTypes.func.isRequired,
+ gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPage: PropTypes.func.isRequired,
+ setCutoffUnmetSort: PropTypes.func.isRequired,
+ setCutoffUnmetFilter: PropTypes.func.isRequired,
+ setCutoffUnmetTableOption: PropTypes.func.isRequired,
+ clearCutoffUnmet: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ fetchEpisodeFiles: PropTypes.func.isRequired,
+ clearEpisodeFiles: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector)
+);
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
new file mode 100644
index 000000000..934076d15
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
@@ -0,0 +1,7 @@
+.episode,
+.language,
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
new file mode 100644
index 000000000..de0b0e161
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
@@ -0,0 +1,175 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import episodeEntities from 'Episode/episodeEntities';
+import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
+import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
+import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
+import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
+import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import styles from './CutoffUnmetRow.css';
+
+function CutoffUnmetRow(props) {
+ const {
+ id,
+ episodeFileId,
+ series,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ unverifiedSceneNumbering,
+ airDateUtc,
+ title,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'series.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episode') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'airDateUtc') {
+ return (
+
+ );
+ }
+
+ if (name === 'language') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+CutoffUnmetRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ episodeFileId: PropTypes.number,
+ series: PropTypes.object.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ unverifiedSceneNumbering: PropTypes.bool.isRequired,
+ airDateUtc: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default CutoffUnmetRow;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js
new file mode 100644
index 000000000..56a4815be
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import CutoffUnmetRow from './CutoffUnmetRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ (series) => {
+ return {
+ series
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CutoffUnmetRow);
diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js
new file mode 100644
index 000000000..a80886e60
--- /dev/null
+++ b/frontend/src/Wanted/Missing/Missing.js
@@ -0,0 +1,298 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import MissingRowConnector from './MissingRowConnector';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class Missing extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllMissingModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ }
+
+ onToggleSelectedPress = () => {
+ const episodeIds = this.getSelectedIds();
+
+ this.props.batchToggleMissingEpisodes({
+ episodeIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ }
+
+ onSearchAllMissingPress = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: true });
+ }
+
+ onSearchAllMissingConfirmed = () => {
+ this.props.onSearchAllMissingPress();
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ }
+
+ onConfirmSearchAllMissingModalClose = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForMissingEpisodes,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllMissingModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ Error fetching missing items
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No missing items
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Are you sure you want to search for all {totalRecords} missing episodes?
+
+
+ This cannot be cancelled once started without restarting Sonarr.
+
+
+ }
+ confirmLabel="Search"
+ onConfirm={this.onSearchAllMissingConfirmed}
+ onCancel={this.onConfirmSearchAllMissingModalClose}
+ />
+
+
+
+ }
+
+
+ );
+ }
+}
+
+Missing.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForMissingEpisodes: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleMissingEpisodes: PropTypes.func.isRequired,
+ onSearchAllMissingPress: PropTypes.func.isRequired
+};
+
+export default Missing;
diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js
new file mode 100644
index 000000000..a1632c6dd
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingConnector.js
@@ -0,0 +1,172 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import * as commandNames from 'Commands/commandNames';
+import Missing from './Missing';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.missing,
+ createCommandExecutingSelector(commandNames.MISSING_EPISODE_SEARCH),
+ (missing, isSearchingForMissingEpisodes) => {
+ return {
+ isSearchingForMissingEpisodes,
+ isSaving: missing.items.filter((m) => m.isSaving).length > 1,
+ ...missing
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class MissingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchMissing,
+ gotoMissingFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['episodeFileUpdated']);
+
+ if (useCurrentPage) {
+ fetchMissing();
+ } else {
+ gotoMissingFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const episodeIds = selectUniqueIds(this.props.items, 'id');
+ this.props.fetchQueueDetails({ episodeIds });
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearMissing();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchMissing();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoMissingFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoMissingPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoMissingNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoMissingLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoMissingPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setMissingSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setMissingFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setMissingTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoMissingFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.EPISODE_SEARCH,
+ episodeIds: selected
+ });
+ }
+
+ onSearchAllMissingPress = () => {
+ this.props.executeCommand({
+ name: commandNames.MISSING_EPISODE_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MissingConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchMissing: PropTypes.func.isRequired,
+ gotoMissingFirstPage: PropTypes.func.isRequired,
+ gotoMissingPreviousPage: PropTypes.func.isRequired,
+ gotoMissingNextPage: PropTypes.func.isRequired,
+ gotoMissingLastPage: PropTypes.func.isRequired,
+ gotoMissingPage: PropTypes.func.isRequired,
+ setMissingSort: PropTypes.func.isRequired,
+ setMissingFilter: PropTypes.func.isRequired,
+ setMissingTableOption: PropTypes.func.isRequired,
+ clearMissing: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(MissingConnector)
+);
diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css
new file mode 100644
index 000000000..3ec895d66
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.css
@@ -0,0 +1,6 @@
+.episode,
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js
new file mode 100644
index 000000000..a8de63bd4
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.js
@@ -0,0 +1,165 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import episodeEntities from 'Episode/episodeEntities';
+import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
+import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
+import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
+import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import styles from './MissingRow.css';
+
+function MissingRow(props) {
+ const {
+ id,
+ episodeFileId,
+ series,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ unverifiedSceneNumbering,
+ airDateUtc,
+ title,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ if (!series) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'series.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episode') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'airDateUtc') {
+ return (
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+MissingRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ episodeFileId: PropTypes.number,
+ series: PropTypes.object.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ unverifiedSceneNumbering: PropTypes.bool.isRequired,
+ airDateUtc: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default MissingRow;
diff --git a/frontend/src/Wanted/Missing/MissingRowConnector.js b/frontend/src/Wanted/Missing/MissingRowConnector.js
new file mode 100644
index 000000000..f7eefca4d
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
+import MissingRow from './MissingRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSeriesSelector(),
+ (series) => {
+ return {
+ series
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MissingRow);
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 000000000..04e0e11ef
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,15 @@
+html,
+body {
+ height: 100%; /* needed for proper layout */
+}
+
+body {
+ overflow: hidden;
+ background-color: #f5f7fa;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ body {
+ overflow-y: auto;
+ }
+}
diff --git a/frontend/src/index.html b/frontend/src/index.html
new file mode 100644
index 000000000..99fbfb103
--- /dev/null
+++ b/frontend/src/index.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sonarr (Preview)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/index.js b/frontend/src/index.js
new file mode 100644
index 000000000..396a7971c
--- /dev/null
+++ b/frontend/src/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import { render } from 'react-dom';
+import createHistory from 'history/createBrowserHistory';
+import createAppStore from 'Store/createAppStore';
+import App from './App/App';
+import 'Styles/globals.css';
+import './index.css';
+
+const history = createHistory();
+const store = createAppStore(history);
+
+render(
+ ,
+ document.getElementById('root')
+);
diff --git a/frontend/src/jQuery/jquery.ajax.js b/frontend/src/jQuery/jquery.ajax.js
new file mode 100644
index 000000000..9b217801e
--- /dev/null
+++ b/frontend/src/jQuery/jquery.ajax.js
@@ -0,0 +1,47 @@
+import $ from 'jquery';
+
+const absUrlRegex = /^(https?:)?\/\//i;
+const apiRoot = window.Sonarr.apiRoot;
+const urlBase = window.Sonarr.urlBase;
+
+function isRelative(xhr) {
+ return !absUrlRegex.test(xhr.url);
+}
+
+function moveBodyToQuery(xhr) {
+ if (xhr.data && xhr.type === 'DELETE') {
+ if (xhr.url.contains('?')) {
+ xhr.url += '&';
+ } else {
+ xhr.url += '?';
+ }
+ xhr.url += $.param(xhr.data);
+ delete xhr.data;
+ }
+}
+
+function addRootUrl(xhr) {
+ const url = xhr.url;
+ if (url.startsWith('/signalr')) {
+ xhr.url = urlBase + xhr.url;
+ } else {
+ xhr.url = apiRoot + xhr.url;
+ }
+}
+
+function addApiKey(xhr) {
+ xhr.headers = xhr.headers || {};
+ xhr.headers['X-Api-Key'] = window.Sonarr.apiKey;
+}
+
+export default function() {
+ const originalAjax = $.ajax;
+ $.ajax = function(xhr) {
+ if (xhr && isRelative(xhr)) {
+ moveBodyToQuery(xhr);
+ addRootUrl(xhr);
+ addApiKey(xhr);
+ }
+ return originalAjax.apply(this, arguments);
+ };
+}
diff --git a/frontend/src/login.html b/frontend/src/login.html
new file mode 100644
index 000000000..930018edf
--- /dev/null
+++ b/frontend/src/login.html
@@ -0,0 +1,226 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login - Sonarr
+
+
+
+
+
+
+
+
+
+
+
+
+ SIGN IN TO CONTINUE
+
+
+
+
+
+
+
+ ©
+
+ -
+ Sonarr
+
+
+
+
+
+
+
diff --git a/frontend/src/oauth.html b/frontend/src/oauth.html
new file mode 100644
index 000000000..16a34dbf3
--- /dev/null
+++ b/frontend/src/oauth.html
@@ -0,0 +1,13 @@
+
+
+
+
+ OAuth landing page
+
+
+
+ Shouldn't see this
+
+
diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js
new file mode 100644
index 000000000..b5d17d598
--- /dev/null
+++ b/frontend/src/polyfills.js
@@ -0,0 +1,41 @@
+/* eslint no-empty-function: 0 no-extend-native: 0 */
+
+window.console = window.console || {};
+window.console.log = window.console.log || function() {};
+window.console.group = window.console.group || function() {};
+window.console.groupEnd = window.console.groupEnd || function() {};
+window.console.debug = window.console.debug || function() {};
+window.console.warn = window.console.warn || function() {};
+window.console.assert = window.console.assert || function() {};
+
+if (!String.prototype.startsWith) {
+ Object.defineProperty(String.prototype, 'startsWith', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value(searchString, position) {
+ position = position || 0;
+ return this.indexOf(searchString, position) === position;
+ }
+ });
+}
+
+if (!String.prototype.endsWith) {
+ Object.defineProperty(String.prototype, 'endsWith', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value(searchString, position) {
+ position = position || this.length;
+ position = position - searchString.length;
+ const lastIndex = this.lastIndexOf(searchString);
+ return lastIndex !== -1 && lastIndex === position;
+ }
+ });
+}
+
+if (!('contains' in String.prototype)) {
+ String.prototype.contains = function(str, startIndex) {
+ return String.prototype.indexOf.call(this, str, startIndex) !== -1;
+ };
+}
diff --git a/frontend/src/preload.js b/frontend/src/preload.js
new file mode 100644
index 000000000..674699db9
--- /dev/null
+++ b/frontend/src/preload.js
@@ -0,0 +1,4 @@
+/* eslint no-undef: 0 */
+import 'Shims/jquery';
+
+__webpack_public_path__ = `${window.Sonarr.urlBase}/`;
diff --git a/frontend/src/vendor.js b/frontend/src/vendor.js
new file mode 100644
index 000000000..68394a5f7
--- /dev/null
+++ b/frontend/src/vendor.js
@@ -0,0 +1,28 @@
+/* Base */
+// require('jquery');
+require('lodash');
+require('moment');
+// require('signalR');
+// require('jquery-ui');
+// require('jquery.easypiechart');
+// require('jquery.dotdotdot');
+// require('typeahead');
+// require('zero.clipboard');
+
+/* Bootstrap */
+// require('bootstrap');
+// require('bootstrap.tagsinput');
+
+/* Backbone */
+// require('backbone');
+// require('backbone.deepmodel');
+// require('backbone.paginator');
+
+// require('backbone.modelbinder');
+// require('backbone.collectionview');
+// require('backgrid');
+// require('backgrid.paginator');
+// require('backgrid.selectall');
+
+// require('marionette'); // this brings in a bunch of our code into this chunk because of template helpers.
+// require('vent');
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
deleted file mode 100644
index dfdacfc11..000000000
--- a/npm-shrinkwrap.json
+++ /dev/null
@@ -1,4809 +0,0 @@
-{
- "name": "Sonarr",
- "version": "2.0.0",
- "dependencies": {
- "acorn": {
- "version": "3.1.0",
- "from": "acorn@>=3.1.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.1.0.tgz"
- },
- "acorn-jsx": {
- "version": "3.0.1",
- "from": "acorn-jsx@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz"
- },
- "acorn-to-esprima": {
- "version": "2.0.8",
- "from": "acorn-to-esprima@>=2.0.6 <3.0.0",
- "resolved": "https://registry.npmjs.org/acorn-to-esprima/-/acorn-to-esprima-2.0.8.tgz"
- },
- "align-text": {
- "version": "0.1.4",
- "from": "align-text@>=0.1.3 <0.2.0",
- "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz"
- },
- "alphanum-sort": {
- "version": "1.0.2",
- "from": "alphanum-sort@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz"
- },
- "amdefine": {
- "version": "1.0.0",
- "from": "amdefine@>=0.0.4",
- "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz"
- },
- "ansi-escapes": {
- "version": "1.4.0",
- "from": "ansi-escapes@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz"
- },
- "ansi-regex": {
- "version": "2.0.0",
- "from": "ansi-regex@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz"
- },
- "ansi-styles": {
- "version": "2.2.1",
- "from": "ansi-styles@>=2.2.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz"
- },
- "anymatch": {
- "version": "1.3.0",
- "from": "anymatch@>=1.3.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.0.tgz"
- },
- "archy": {
- "version": "1.0.0",
- "from": "archy@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz"
- },
- "argparse": {
- "version": "1.0.7",
- "from": "argparse@>=1.0.7 <2.0.0",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.7.tgz"
- },
- "arr-diff": {
- "version": "2.0.0",
- "from": "arr-diff@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz"
- },
- "arr-flatten": {
- "version": "1.0.1",
- "from": "arr-flatten@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.1.tgz"
- },
- "array-differ": {
- "version": "1.0.0",
- "from": "array-differ@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz"
- },
- "array-find-index": {
- "version": "1.0.1",
- "from": "array-find-index@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.1.tgz"
- },
- "array-union": {
- "version": "1.0.1",
- "from": "array-union@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.1.tgz"
- },
- "array-uniq": {
- "version": "1.0.2",
- "from": "array-uniq@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.2.tgz"
- },
- "array-unique": {
- "version": "0.2.1",
- "from": "array-unique@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz"
- },
- "arrify": {
- "version": "1.0.1",
- "from": "arrify@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz"
- },
- "asap": {
- "version": "2.0.4",
- "from": "asap@>=2.0.3 <2.1.0",
- "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.4.tgz"
- },
- "assert": {
- "version": "1.4.0",
- "from": "assert@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.0.tgz"
- },
- "assertion-error": {
- "version": "1.0.1",
- "from": "assertion-error@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.1.tgz"
- },
- "async": {
- "version": "0.2.10",
- "from": "async@>=0.2.6 <0.3.0",
- "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz"
- },
- "async-each": {
- "version": "1.0.0",
- "from": "async-each@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.0.tgz"
- },
- "autoprefixer": {
- "version": "6.3.6",
- "from": "autoprefixer@6.3.6",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.3.6.tgz"
- },
- "babel-code-frame": {
- "version": "6.8.0",
- "from": "babel-code-frame@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.8.0.tgz"
- },
- "babel-core": {
- "version": "6.9.0",
- "from": "babel-core@6.9.0",
- "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.9.0.tgz"
- },
- "babel-eslint": {
- "version": "7.1.0",
- "from": "babel-eslint@7.1.0",
- "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-7.1.0.tgz",
- "dependencies": {
- "babel-code-frame": {
- "version": "6.16.0",
- "from": "babel-code-frame@>=6.16.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.16.0.tgz"
- },
- "babel-traverse": {
- "version": "6.18.0",
- "from": "babel-traverse@>=6.15.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.18.0.tgz"
- },
- "babel-types": {
- "version": "6.18.0",
- "from": "babel-types@>=6.15.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.18.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.18.0",
- "from": "babel-runtime@>=6.9.1 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.18.0.tgz"
- }
- }
- },
- "babylon": {
- "version": "6.13.1",
- "from": "babylon@>=6.11.2 <7.0.0",
- "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.13.1.tgz"
- },
- "globals": {
- "version": "9.12.0",
- "from": "globals@>=9.0.0 <10.0.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-9.12.0.tgz"
- },
- "js-tokens": {
- "version": "2.0.0",
- "from": "js-tokens@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-2.0.0.tgz"
- }
- }
- },
- "babel-generator": {
- "version": "6.9.0",
- "from": "babel-generator@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.9.0.tgz"
- },
- "babel-helper-builder-binary-assignment-operator-visitor": {
- "version": "6.8.0",
- "from": "babel-helper-builder-binary-assignment-operator-visitor@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.8.0.tgz"
- },
- "babel-helper-builder-react-jsx": {
- "version": "6.22.0",
- "from": "babel-helper-builder-react-jsx@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.22.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.22.0",
- "from": "babel-runtime@^6.22.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz"
- },
- "babel-types": {
- "version": "6.22.0",
- "from": "babel-types@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.22.0.tgz"
- },
- "regenerator-runtime": {
- "version": "0.10.1",
- "from": "regenerator-runtime@^0.10.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz"
- }
- }
- },
- "babel-helper-call-delegate": {
- "version": "6.8.0",
- "from": "babel-helper-call-delegate@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.8.0.tgz"
- },
- "babel-helper-define-map": {
- "version": "6.9.0",
- "from": "babel-helper-define-map@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.9.0.tgz"
- },
- "babel-helper-explode-assignable-expression": {
- "version": "6.8.0",
- "from": "babel-helper-explode-assignable-expression@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.8.0.tgz"
- },
- "babel-helper-function-name": {
- "version": "6.8.0",
- "from": "babel-helper-function-name@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.8.0.tgz"
- },
- "babel-helper-get-function-arity": {
- "version": "6.8.0",
- "from": "babel-helper-get-function-arity@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.8.0.tgz"
- },
- "babel-helper-hoist-variables": {
- "version": "6.8.0",
- "from": "babel-helper-hoist-variables@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.8.0.tgz"
- },
- "babel-helper-optimise-call-expression": {
- "version": "6.8.0",
- "from": "babel-helper-optimise-call-expression@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.8.0.tgz"
- },
- "babel-helper-regex": {
- "version": "6.9.0",
- "from": "babel-helper-regex@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.9.0.tgz"
- },
- "babel-helper-remap-async-to-generator": {
- "version": "6.11.2",
- "from": "babel-helper-remap-async-to-generator@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.11.2.tgz"
- },
- "babel-helper-replace-supers": {
- "version": "6.8.0",
- "from": "babel-helper-replace-supers@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.8.0.tgz"
- },
- "babel-helpers": {
- "version": "6.8.0",
- "from": "babel-helpers@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.8.0.tgz"
- },
- "babel-loader": {
- "version": "6.2.4",
- "from": "babel-loader@6.2.4",
- "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-6.2.4.tgz"
- },
- "babel-messages": {
- "version": "6.8.0",
- "from": "babel-messages@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.8.0.tgz"
- },
- "babel-plugin-check-es2015-constants": {
- "version": "6.8.0",
- "from": "babel-plugin-check-es2015-constants@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.8.0.tgz"
- },
- "babel-plugin-syntax-async-functions": {
- "version": "6.8.0",
- "from": "babel-plugin-syntax-async-functions@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.8.0.tgz"
- },
- "babel-plugin-syntax-class-properties": {
- "version": "6.13.0",
- "from": "babel-plugin-syntax-class-properties@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz"
- },
- "babel-plugin-syntax-decorators": {
- "version": "6.8.0",
- "from": "babel-plugin-syntax-decorators@>=6.1.18 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.8.0.tgz"
- },
- "babel-plugin-syntax-exponentiation-operator": {
- "version": "6.8.0",
- "from": "babel-plugin-syntax-exponentiation-operator@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.8.0.tgz"
- },
- "babel-plugin-syntax-flow": {
- "version": "6.18.0",
- "from": "babel-plugin-syntax-flow@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz"
- },
- "babel-plugin-syntax-jsx": {
- "version": "6.18.0",
- "from": "babel-plugin-syntax-jsx@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz"
- },
- "babel-plugin-syntax-object-rest-spread": {
- "version": "6.8.0",
- "from": "babel-plugin-syntax-object-rest-spread@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.8.0.tgz"
- },
- "babel-plugin-syntax-trailing-function-commas": {
- "version": "6.8.0",
- "from": "babel-plugin-syntax-trailing-function-commas@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.8.0.tgz"
- },
- "babel-plugin-transform-async-to-generator": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-async-to-generator@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.8.0.tgz"
- },
- "babel-plugin-transform-class-properties": {
- "version": "6.16.0",
- "from": "babel-plugin-transform-class-properties@6.16.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.16.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.11.6",
- "from": "babel-runtime@>=6.9.1 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.11.6.tgz"
- }
- }
- },
- "babel-plugin-transform-decorators-legacy": {
- "version": "1.3.4",
- "from": "babel-plugin-transform-decorators-legacy@>=1.3.4 <2.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.4.tgz"
- },
- "babel-plugin-transform-es2015-arrow-functions": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-arrow-functions@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-block-scoped-functions": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-block-scoped-functions@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-block-scoping": {
- "version": "6.9.0",
- "from": "babel-plugin-transform-es2015-block-scoping@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.9.0.tgz"
- },
- "babel-plugin-transform-es2015-classes": {
- "version": "6.9.0",
- "from": "babel-plugin-transform-es2015-classes@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.9.0.tgz"
- },
- "babel-plugin-transform-es2015-computed-properties": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-computed-properties@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-destructuring": {
- "version": "6.9.0",
- "from": "babel-plugin-transform-es2015-destructuring@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.9.0.tgz"
- },
- "babel-plugin-transform-es2015-duplicate-keys": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-duplicate-keys@>=6.6.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-for-of": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-for-of@>=6.6.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-function-name": {
- "version": "6.9.0",
- "from": "babel-plugin-transform-es2015-function-name@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.9.0.tgz"
- },
- "babel-plugin-transform-es2015-literals": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-literals@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-modules-commonjs": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-modules-commonjs@>=6.6.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-object-super": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-object-super@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-parameters": {
- "version": "6.9.0",
- "from": "babel-plugin-transform-es2015-parameters@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.9.0.tgz"
- },
- "babel-plugin-transform-es2015-shorthand-properties": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-shorthand-properties@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-spread": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-spread@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-sticky-regex": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-sticky-regex@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-template-literals": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-template-literals@>=6.6.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-typeof-symbol": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-typeof-symbol@>=6.6.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.8.0.tgz"
- },
- "babel-plugin-transform-es2015-unicode-regex": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-es2015-unicode-regex@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.8.0.tgz"
- },
- "babel-plugin-transform-exponentiation-operator": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-exponentiation-operator@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.8.0.tgz"
- },
- "babel-plugin-transform-flow-strip-types": {
- "version": "6.22.0",
- "from": "babel-plugin-transform-flow-strip-types@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.22.0",
- "from": "babel-runtime@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz"
- },
- "regenerator-runtime": {
- "version": "0.10.1",
- "from": "regenerator-runtime@>=0.10.0 <0.11.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz"
- }
- }
- },
- "babel-plugin-transform-object-rest-spread": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-object-rest-spread@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.8.0.tgz"
- },
- "babel-plugin-transform-react-display-name": {
- "version": "6.22.0",
- "from": "babel-plugin-transform-react-display-name@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.22.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.22.0",
- "from": "babel-runtime@^6.22.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz"
- },
- "regenerator-runtime": {
- "version": "0.10.1",
- "from": "regenerator-runtime@^0.10.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz"
- }
- }
- },
- "babel-plugin-transform-react-jsx": {
- "version": "6.22.0",
- "from": "babel-plugin-transform-react-jsx@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.22.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.22.0",
- "from": "babel-runtime@^6.22.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz"
- },
- "regenerator-runtime": {
- "version": "0.10.1",
- "from": "regenerator-runtime@^0.10.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz"
- }
- }
- },
- "babel-plugin-transform-react-jsx-self": {
- "version": "6.22.0",
- "from": "babel-plugin-transform-react-jsx-self@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.22.0",
- "from": "babel-runtime@^6.22.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz"
- },
- "regenerator-runtime": {
- "version": "0.10.1",
- "from": "regenerator-runtime@^0.10.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz"
- }
- }
- },
- "babel-plugin-transform-react-jsx-source": {
- "version": "6.22.0",
- "from": "babel-plugin-transform-react-jsx-source@>=6.22.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.22.0",
- "from": "babel-runtime@^6.22.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz"
- },
- "regenerator-runtime": {
- "version": "0.10.1",
- "from": "regenerator-runtime@^0.10.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz"
- }
- }
- },
- "babel-plugin-transform-regenerator": {
- "version": "6.9.0",
- "from": "babel-plugin-transform-regenerator@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.9.0.tgz"
- },
- "babel-plugin-transform-strict-mode": {
- "version": "6.8.0",
- "from": "babel-plugin-transform-strict-mode@>=6.8.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.8.0.tgz"
- },
- "babel-preset-decorators-legacy": {
- "version": "1.0.0",
- "from": "babel-preset-decorators-legacy@1.0.0",
- "resolved": "https://registry.npmjs.org/babel-preset-decorators-legacy/-/babel-preset-decorators-legacy-1.0.0.tgz"
- },
- "babel-preset-es2015": {
- "version": "6.9.0",
- "from": "babel-preset-es2015@6.9.0",
- "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.9.0.tgz"
- },
- "babel-preset-react": {
- "version": "6.22.0",
- "from": "babel-preset-react@6.22.0",
- "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.22.0.tgz"
- },
- "babel-preset-stage-2": {
- "version": "6.5.0",
- "from": "babel-preset-stage-2@6.5.0",
- "resolved": "https://registry.npmjs.org/babel-preset-stage-2/-/babel-preset-stage-2-6.5.0.tgz"
- },
- "babel-preset-stage-3": {
- "version": "6.11.0",
- "from": "babel-preset-stage-3@>=6.3.13 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-preset-stage-3/-/babel-preset-stage-3-6.11.0.tgz"
- },
- "babel-register": {
- "version": "6.9.0",
- "from": "babel-register@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.9.0.tgz"
- },
- "babel-runtime": {
- "version": "6.9.0",
- "from": "babel-runtime@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.9.0.tgz"
- },
- "babel-template": {
- "version": "6.9.0",
- "from": "babel-template@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.9.0.tgz"
- },
- "babel-traverse": {
- "version": "6.9.0",
- "from": "babel-traverse@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.9.0.tgz"
- },
- "babel-types": {
- "version": "6.9.0",
- "from": "babel-types@>=6.9.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.9.0.tgz"
- },
- "babylon": {
- "version": "6.8.0",
- "from": "babylon@>=6.7.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.8.0.tgz"
- },
- "balanced-match": {
- "version": "0.4.1",
- "from": "balanced-match@>=0.4.1 <0.5.0",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
- },
- "Base64": {
- "version": "0.2.1",
- "from": "Base64@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/Base64/-/Base64-0.2.1.tgz"
- },
- "base64-js": {
- "version": "0.0.8",
- "from": "base64-js@0.0.8",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz"
- },
- "beeper": {
- "version": "1.1.0",
- "from": "beeper@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.0.tgz"
- },
- "big.js": {
- "version": "3.1.3",
- "from": "big.js@>=3.1.3 <4.0.0",
- "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.1.3.tgz"
- },
- "binary-extensions": {
- "version": "1.4.0",
- "from": "binary-extensions@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.4.0.tgz"
- },
- "bl": {
- "version": "0.7.0",
- "from": "bl@>=0.7.0 <0.8.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-0.7.0.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.2 <1.1.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- }
- }
- },
- "block-stream": {
- "version": "0.0.9",
- "from": "block-stream@*",
- "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz"
- },
- "bluebird": {
- "version": "3.4.0",
- "from": "bluebird@>=3.1.1 <4.0.0",
- "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.0.tgz"
- },
- "body-parser": {
- "version": "1.14.2",
- "from": "body-parser@>=1.14.0 <1.15.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.14.2.tgz",
- "dependencies": {
- "qs": {
- "version": "5.2.0",
- "from": "qs@5.2.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-5.2.0.tgz"
- }
- }
- },
- "brace-expansion": {
- "version": "1.1.4",
- "from": "brace-expansion@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz"
- },
- "braces": {
- "version": "1.8.5",
- "from": "braces@>=1.8.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz"
- },
- "browserify-zlib": {
- "version": "0.1.4",
- "from": "browserify-zlib@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz"
- },
- "browserslist": {
- "version": "1.3.4",
- "from": "browserslist@>=1.3.1 <1.4.0",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.3.4.tgz"
- },
- "buffer": {
- "version": "3.6.0",
- "from": "buffer@>=3.0.3 <4.0.0",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-3.6.0.tgz"
- },
- "buffer-shims": {
- "version": "1.0.0",
- "from": "buffer-shims@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
- },
- "bufferstreams": {
- "version": "1.0.1",
- "from": "bufferstreams@1.0.1",
- "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-1.0.1.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.1.14",
- "from": "readable-stream@>=1.0.33 <2.0.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz"
- }
- }
- },
- "builtin-modules": {
- "version": "1.1.1",
- "from": "builtin-modules@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz"
- },
- "bytes": {
- "version": "2.2.0",
- "from": "bytes@2.2.0",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.2.0.tgz"
- },
- "caller-path": {
- "version": "0.1.0",
- "from": "caller-path@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz"
- },
- "callsites": {
- "version": "0.2.0",
- "from": "callsites@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz"
- },
- "camelcase": {
- "version": "2.1.1",
- "from": "camelcase@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz"
- },
- "camelcase-keys": {
- "version": "2.1.0",
- "from": "camelcase-keys@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz"
- },
- "caniuse-db": {
- "version": "1.0.30000492",
- "from": "caniuse-db@>=1.0.30000444 <2.0.0",
- "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000492.tgz"
- },
- "center-align": {
- "version": "0.1.3",
- "from": "center-align@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz"
- },
- "chai": {
- "version": "3.5.0",
- "from": "chai@>=3.2.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz"
- },
- "chalk": {
- "version": "1.1.3",
- "from": "chalk@>=1.1.3 <2.0.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
- "dependencies": {
- "supports-color": {
- "version": "2.0.0",
- "from": "supports-color@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz"
- }
- }
- },
- "chokidar": {
- "version": "1.5.1",
- "from": "chokidar@>=1.0.3 <2.0.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.5.1.tgz"
- },
- "circular-json": {
- "version": "0.3.1",
- "from": "circular-json@>=0.3.1 <0.4.0",
- "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.1.tgz"
- },
- "clap": {
- "version": "1.1.1",
- "from": "clap@>=1.0.9 <2.0.0",
- "resolved": "https://registry.npmjs.org/clap/-/clap-1.1.1.tgz"
- },
- "classnames": {
- "version": "2.2.5",
- "from": "classnames@2.2.5",
- "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz"
- },
- "cli-cursor": {
- "version": "1.0.2",
- "from": "cli-cursor@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz"
- },
- "cli-width": {
- "version": "2.1.0",
- "from": "cli-width@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.1.0.tgz"
- },
- "cliui": {
- "version": "2.1.0",
- "from": "cliui@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz",
- "dependencies": {
- "wordwrap": {
- "version": "0.0.2",
- "from": "wordwrap@0.0.2",
- "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz"
- }
- }
- },
- "clone": {
- "version": "1.0.2",
- "from": "clone@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz"
- },
- "clone-regexp": {
- "version": "1.0.0",
- "from": "clone-regexp@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-1.0.0.tgz"
- },
- "clone-stats": {
- "version": "0.0.1",
- "from": "clone-stats@>=0.0.1 <0.0.2",
- "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz"
- },
- "coa": {
- "version": "1.0.1",
- "from": "coa@>=1.0.1 <1.1.0",
- "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.1.tgz"
- },
- "code-point-at": {
- "version": "1.0.0",
- "from": "code-point-at@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.0.0.tgz"
- },
- "color": {
- "version": "0.11.3",
- "from": "color@>=0.11.0 <0.12.0",
- "resolved": "https://registry.npmjs.org/color/-/color-0.11.3.tgz"
- },
- "color-convert": {
- "version": "1.3.1",
- "from": "color-convert@>=1.3.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.3.1.tgz"
- },
- "color-diff": {
- "version": "0.1.7",
- "from": "color-diff@>=0.1.3 <0.2.0",
- "resolved": "https://registry.npmjs.org/color-diff/-/color-diff-0.1.7.tgz"
- },
- "color-name": {
- "version": "1.1.1",
- "from": "color-name@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz"
- },
- "color-string": {
- "version": "0.3.0",
- "from": "color-string@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz"
- },
- "colorguard": {
- "version": "1.2.0",
- "from": "colorguard@>=1.2.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/colorguard/-/colorguard-1.2.0.tgz",
- "dependencies": {
- "yargs": {
- "version": "1.3.3",
- "from": "yargs@>=1.2.6 <2.0.0",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-1.3.3.tgz"
- }
- }
- },
- "colormin": {
- "version": "1.1.0",
- "from": "colormin@>=1.0.5 <2.0.0",
- "resolved": "https://registry.npmjs.org/colormin/-/colormin-1.1.0.tgz"
- },
- "colors": {
- "version": "1.1.2",
- "from": "colors@>=1.1.2 <1.2.0",
- "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz"
- },
- "commander": {
- "version": "2.9.0",
- "from": "commander@>=2.2.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz"
- },
- "concat-map": {
- "version": "0.0.1",
- "from": "concat-map@0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
- },
- "concat-stream": {
- "version": "1.5.1",
- "from": "concat-stream@>=1.4.7 <2.0.0",
- "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.5.1.tgz"
- },
- "concat-with-sourcemaps": {
- "version": "1.0.4",
- "from": "concat-with-sourcemaps@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.0.4.tgz"
- },
- "console-browserify": {
- "version": "1.1.0",
- "from": "console-browserify@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz"
- },
- "consolidate": {
- "version": "0.14.1",
- "from": "consolidate@>=0.14.1 <0.15.0",
- "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.14.1.tgz"
- },
- "constants-browserify": {
- "version": "0.0.1",
- "from": "constants-browserify@0.0.1",
- "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-0.0.1.tgz"
- },
- "content-type": {
- "version": "1.0.2",
- "from": "content-type@>=1.0.1 <1.1.0",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz"
- },
- "convert-source-map": {
- "version": "1.2.0",
- "from": "convert-source-map@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.2.0.tgz"
- },
- "core-js": {
- "version": "2.4.0",
- "from": "core-js@>=2.4.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.4.0.tgz"
- },
- "core-util-is": {
- "version": "1.0.2",
- "from": "core-util-is@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
- },
- "cosmiconfig": {
- "version": "1.1.0",
- "from": "cosmiconfig@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-1.1.0.tgz"
- },
- "crypto-browserify": {
- "version": "3.2.8",
- "from": "crypto-browserify@>=3.2.6 <3.3.0",
- "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.2.8.tgz"
- },
- "css-color-names": {
- "version": "0.0.3",
- "from": "css-color-names@0.0.3",
- "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.3.tgz"
- },
- "css-loader": {
- "version": "0.23.1",
- "from": "css-loader@0.23.1",
- "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-0.23.1.tgz"
- },
- "css-rule-stream": {
- "version": "1.1.0",
- "from": "css-rule-stream@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/css-rule-stream/-/css-rule-stream-1.1.0.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.3 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- }
- }
- },
- "css-selector-tokenizer": {
- "version": "0.5.4",
- "from": "css-selector-tokenizer@>=0.5.1 <0.6.0",
- "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.5.4.tgz"
- },
- "css-tokenize": {
- "version": "1.0.1",
- "from": "css-tokenize@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/css-tokenize/-/css-tokenize-1.0.1.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.1.14",
- "from": "readable-stream@>=1.0.33 <2.0.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz"
- }
- }
- },
- "cssesc": {
- "version": "0.1.0",
- "from": "cssesc@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-0.1.0.tgz"
- },
- "cssnano": {
- "version": "3.7.1",
- "from": "cssnano@>=2.6.1 <4.0.0",
- "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.7.1.tgz"
- },
- "csso": {
- "version": "2.0.0",
- "from": "csso@>=2.0.0 <2.1.0",
- "resolved": "https://registry.npmjs.org/csso/-/csso-2.0.0.tgz"
- },
- "d": {
- "version": "0.1.1",
- "from": "d@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz"
- },
- "date-now": {
- "version": "0.1.4",
- "from": "date-now@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz"
- },
- "dateformat": {
- "version": "1.0.12",
- "from": "dateformat@>=1.0.11 <2.0.0",
- "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz"
- },
- "debug": {
- "version": "2.2.0",
- "from": "debug@>=2.1.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz"
- },
- "decamelize": {
- "version": "1.2.0",
- "from": "decamelize@>=1.1.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
- },
- "deep-eql": {
- "version": "0.1.3",
- "from": "deep-eql@>=0.1.3 <0.2.0",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz",
- "dependencies": {
- "type-detect": {
- "version": "0.1.1",
- "from": "type-detect@0.1.1",
- "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz"
- }
- }
- },
- "deep-is": {
- "version": "0.1.3",
- "from": "deep-is@>=0.1.3 <0.2.0",
- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz"
- },
- "defaults": {
- "version": "1.0.3",
- "from": "defaults@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz"
- },
- "defined": {
- "version": "1.0.0",
- "from": "defined@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz"
- },
- "del": {
- "version": "2.2.0",
- "from": "del@2.2.0",
- "resolved": "https://registry.npmjs.org/del/-/del-2.2.0.tgz"
- },
- "depd": {
- "version": "1.1.0",
- "from": "depd@>=1.1.0 <1.2.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz"
- },
- "deprecated": {
- "version": "0.0.1",
- "from": "deprecated@>=0.0.1 <0.0.2",
- "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz"
- },
- "detect-indent": {
- "version": "3.0.1",
- "from": "detect-indent@>=3.0.1 <4.0.0",
- "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-3.0.1.tgz"
- },
- "diff": {
- "version": "1.4.0",
- "from": "diff@>=1.3.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/diff/-/diff-1.4.0.tgz"
- },
- "disparity": {
- "version": "2.0.0",
- "from": "disparity@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/disparity/-/disparity-2.0.0.tgz"
- },
- "disposables": {
- "version": "1.0.1",
- "from": "disposables@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/disposables/-/disposables-1.0.1.tgz"
- },
- "dnd-core": {
- "version": "2.0.2",
- "from": "dnd-core@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.0.2.tgz"
- },
- "doctrine": {
- "version": "1.2.2",
- "from": "doctrine@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.2.2.tgz",
- "dependencies": {
- "esutils": {
- "version": "1.1.6",
- "from": "esutils@>=1.1.6 <2.0.0",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-1.1.6.tgz"
- }
- }
- },
- "doiuse": {
- "version": "2.4.1",
- "from": "doiuse@>=2.4.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/doiuse/-/doiuse-2.4.1.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "source-map": {
- "version": "0.4.4",
- "from": "source-map@>=0.4.2 <0.5.0",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.3 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- }
- }
- },
- "dom-helpers": {
- "version": "3.2.0",
- "from": "dom-helpers@>=2.4.0 <3.0.0||>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.2.0.tgz"
- },
- "domain-browser": {
- "version": "1.1.7",
- "from": "domain-browser@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz"
- },
- "duplexer": {
- "version": "0.1.1",
- "from": "duplexer@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz"
- },
- "duplexer2": {
- "version": "0.0.2",
- "from": "duplexer2@0.0.2",
- "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.1.14",
- "from": "readable-stream@>=1.1.9 <1.2.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz"
- }
- }
- },
- "ee-first": {
- "version": "1.1.1",
- "from": "ee-first@1.1.1",
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
- },
- "element-class": {
- "version": "0.2.2",
- "from": "element-class@latest",
- "resolved": "https://registry.npmjs.org/element-class/-/element-class-0.2.2.tgz"
- },
- "emojis-list": {
- "version": "2.0.1",
- "from": "emojis-list@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.0.1.tgz"
- },
- "encoding": {
- "version": "0.1.12",
- "from": "encoding@>=0.1.11 <0.2.0",
- "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz"
- },
- "end-of-stream": {
- "version": "0.1.5",
- "from": "end-of-stream@>=0.1.5 <0.2.0",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz"
- },
- "enhanced-resolve": {
- "version": "0.9.1",
- "from": "enhanced-resolve@>=0.9.0 <0.10.0",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz",
- "dependencies": {
- "memory-fs": {
- "version": "0.2.0",
- "from": "memory-fs@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz"
- }
- }
- },
- "errno": {
- "version": "0.1.4",
- "from": "errno@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.4.tgz"
- },
- "error-ex": {
- "version": "1.3.0",
- "from": "error-ex@>=1.2.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.0.tgz"
- },
- "es5-ext": {
- "version": "0.10.11",
- "from": "es5-ext@>=0.10.8 <0.11.0",
- "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.11.tgz"
- },
- "es6-iterator": {
- "version": "2.0.0",
- "from": "es6-iterator@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.0.tgz"
- },
- "es6-map": {
- "version": "0.1.4",
- "from": "es6-map@>=0.1.3 <0.2.0",
- "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.4.tgz",
- "dependencies": {
- "es6-symbol": {
- "version": "3.1.0",
- "from": "es6-symbol@>=3.1.0 <3.2.0",
- "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.0.tgz"
- }
- }
- },
- "es6-promise": {
- "version": "3.2.1",
- "from": "es6-promise@>=3.1.2 <4.0.0",
- "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz"
- },
- "es6-set": {
- "version": "0.1.4",
- "from": "es6-set@>=0.1.3 <0.2.0",
- "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.4.tgz"
- },
- "es6-symbol": {
- "version": "3.0.2",
- "from": "es6-symbol@>=3.0.1 <3.1.0",
- "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.0.2.tgz"
- },
- "es6-weak-map": {
- "version": "2.0.1",
- "from": "es6-weak-map@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.1.tgz"
- },
- "escape-string-regexp": {
- "version": "1.0.5",
- "from": "escape-string-regexp@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
- },
- "escope": {
- "version": "3.6.0",
- "from": "escope@>=3.6.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz"
- },
- "esformatter": {
- "version": "0.9.3",
- "from": "esformatter@0.9.3",
- "resolved": "https://registry.npmjs.org/esformatter/-/esformatter-0.9.3.tgz",
- "dependencies": {
- "debug": {
- "version": "0.7.4",
- "from": "debug@>=0.7.4 <0.8.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz"
- },
- "glob": {
- "version": "5.0.15",
- "from": "glob@>=5.0.3 <6.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz"
- },
- "supports-color": {
- "version": "1.3.1",
- "from": "supports-color@>=1.3.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.3.1.tgz"
- },
- "user-home": {
- "version": "2.0.0",
- "from": "user-home@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz"
- }
- }
- },
- "eslint": {
- "version": "2.10.2",
- "from": "eslint@2.10.2",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-2.10.2.tgz",
- "dependencies": {
- "glob": {
- "version": "7.1.1",
- "from": "glob@>=7.0.3 <8.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz"
- },
- "globals": {
- "version": "9.14.0",
- "from": "globals@>=9.2.0 <10.0.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-9.14.0.tgz"
- },
- "minimatch": {
- "version": "3.0.3",
- "from": "minimatch@>=3.0.2 <4.0.0",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz"
- },
- "strip-json-comments": {
- "version": "1.0.4",
- "from": "strip-json-comments@>=1.0.1 <1.1.0",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz"
- },
- "user-home": {
- "version": "2.0.0",
- "from": "user-home@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz"
- }
- }
- },
- "eslint-loader": {
- "version": "1.3.0",
- "from": "eslint-loader@1.3.0",
- "resolved": "https://registry.npmjs.org/eslint-loader/-/eslint-loader-1.3.0.tgz"
- },
- "eslint-plugin-filenames": {
- "version": "1.0.0",
- "from": "eslint-plugin-filenames@1.0.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-filenames/-/eslint-plugin-filenames-1.0.0.tgz"
- },
- "eslint-plugin-react": {
- "version": "5.2.2",
- "from": "eslint-plugin-react@5.2.2",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-5.2.2.tgz"
- },
- "espree": {
- "version": "3.1.4",
- "from": "espree@3.1.4",
- "resolved": "https://registry.npmjs.org/espree/-/espree-3.1.4.tgz"
- },
- "esprima": {
- "version": "2.7.2",
- "from": "esprima@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.2.tgz"
- },
- "esrecurse": {
- "version": "4.1.0",
- "from": "esrecurse@>=4.1.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.1.0.tgz",
- "dependencies": {
- "estraverse": {
- "version": "4.1.1",
- "from": "estraverse@>=4.1.0 <4.2.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.1.1.tgz"
- }
- }
- },
- "estraverse": {
- "version": "4.2.0",
- "from": "estraverse@>=4.2.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz"
- },
- "esutils": {
- "version": "2.0.2",
- "from": "esutils@>=2.0.2 <3.0.0",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz"
- },
- "event-emitter": {
- "version": "0.3.4",
- "from": "event-emitter@>=0.3.4 <0.4.0",
- "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.4.tgz"
- },
- "event-stream": {
- "version": "3.3.2",
- "from": "event-stream@>=3.1.7 <4.0.0",
- "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.2.tgz"
- },
- "events": {
- "version": "1.1.0",
- "from": "events@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/events/-/events-1.1.0.tgz"
- },
- "execall": {
- "version": "1.0.0",
- "from": "execall@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/execall/-/execall-1.0.0.tgz"
- },
- "exenv": {
- "version": "1.2.1",
- "from": "exenv@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.1.tgz"
- },
- "exit-hook": {
- "version": "1.1.1",
- "from": "exit-hook@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz"
- },
- "expand-brackets": {
- "version": "0.1.5",
- "from": "expand-brackets@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz"
- },
- "expand-range": {
- "version": "1.8.2",
- "from": "expand-range@>=1.8.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz"
- },
- "extend": {
- "version": "2.0.1",
- "from": "extend@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/extend/-/extend-2.0.1.tgz"
- },
- "extglob": {
- "version": "0.3.2",
- "from": "extglob@>=0.3.1 <0.4.0",
- "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz"
- },
- "extract-text-webpack-plugin": {
- "version": "1.0.1",
- "from": "extract-text-webpack-plugin@1.0.1",
- "resolved": "https://registry.npmjs.org/extract-text-webpack-plugin/-/extract-text-webpack-plugin-1.0.1.tgz",
- "dependencies": {
- "async": {
- "version": "1.5.2",
- "from": "async@>=1.5.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz"
- }
- }
- },
- "fancy-log": {
- "version": "1.2.0",
- "from": "fancy-log@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.2.0.tgz"
- },
- "fast-levenshtein": {
- "version": "2.0.5",
- "from": "fast-levenshtein@>=2.0.4 <2.1.0",
- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz"
- },
- "fastparse": {
- "version": "1.1.1",
- "from": "fastparse@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.1.tgz"
- },
- "faye-websocket": {
- "version": "0.7.3",
- "from": "faye-websocket@>=0.7.2 <0.8.0",
- "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.7.3.tgz"
- },
- "fbjs": {
- "version": "0.8.8",
- "from": "fbjs@>=0.8.4 <0.9.0",
- "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.8.tgz",
- "dependencies": {
- "core-js": {
- "version": "1.2.7",
- "from": "core-js@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz"
- }
- }
- },
- "figures": {
- "version": "1.7.0",
- "from": "figures@>=1.3.5 <2.0.0",
- "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz"
- },
- "file-entry-cache": {
- "version": "1.3.1",
- "from": "file-entry-cache@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-1.3.1.tgz"
- },
- "file-loader": {
- "version": "0.9.0",
- "from": "file-loader@0.9.0",
- "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-0.9.0.tgz"
- },
- "filename-regex": {
- "version": "2.0.0",
- "from": "filename-regex@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.0.tgz"
- },
- "filesize": {
- "version": "3.5.4",
- "from": "filesize@latest",
- "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.5.4.tgz"
- },
- "fill-range": {
- "version": "2.2.3",
- "from": "fill-range@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz"
- },
- "find-index": {
- "version": "0.1.1",
- "from": "find-index@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz"
- },
- "find-up": {
- "version": "1.1.2",
- "from": "find-up@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
- "dependencies": {
- "path-exists": {
- "version": "2.1.0",
- "from": "path-exists@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz"
- }
- }
- },
- "findup-sync": {
- "version": "0.3.0",
- "from": "findup-sync@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.3.0.tgz",
- "dependencies": {
- "glob": {
- "version": "5.0.15",
- "from": "glob@>=5.0.0 <5.1.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz"
- }
- }
- },
- "first-chunk-stream": {
- "version": "1.0.0",
- "from": "first-chunk-stream@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz"
- },
- "flagged-respawn": {
- "version": "0.3.2",
- "from": "flagged-respawn@>=0.3.2 <0.4.0",
- "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-0.3.2.tgz"
- },
- "flat-cache": {
- "version": "1.2.1",
- "from": "flat-cache@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.2.1.tgz"
- },
- "flatten": {
- "version": "1.0.2",
- "from": "flatten@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.2.tgz"
- },
- "fobject": {
- "version": "0.0.4",
- "from": "fobject@0.0.4",
- "resolved": "https://registry.npmjs.org/fobject/-/fobject-0.0.4.tgz",
- "dependencies": {
- "semver": {
- "version": "5.1.0",
- "from": "semver@>=5.1.0 <6.0.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.1.0.tgz"
- }
- }
- },
- "for-in": {
- "version": "0.1.5",
- "from": "for-in@>=0.1.5 <0.2.0",
- "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.5.tgz"
- },
- "for-own": {
- "version": "0.1.4",
- "from": "for-own@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.4.tgz"
- },
- "from": {
- "version": "0.1.3",
- "from": "from@>=0.0.0 <1.0.0",
- "resolved": "https://registry.npmjs.org/from/-/from-0.1.3.tgz"
- },
- "fs-readfile-promise": {
- "version": "2.0.1",
- "from": "fs-readfile-promise@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz"
- },
- "fs.realpath": {
- "version": "1.0.0",
- "from": "fs.realpath@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
- },
- "fstream": {
- "version": "1.0.9",
- "from": "fstream@>=1.0.7 <2.0.0",
- "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.9.tgz"
- },
- "gather-stream": {
- "version": "1.0.0",
- "from": "gather-stream@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/gather-stream/-/gather-stream-1.0.0.tgz"
- },
- "gaze": {
- "version": "0.5.2",
- "from": "gaze@>=0.5.1 <0.6.0",
- "resolved": "https://registry.npmjs.org/gaze/-/gaze-0.5.2.tgz"
- },
- "generate-function": {
- "version": "2.0.0",
- "from": "generate-function@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz"
- },
- "generate-object-property": {
- "version": "1.2.0",
- "from": "generate-object-property@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz"
- },
- "get-node-dimensions": {
- "version": "1.2.0",
- "from": "get-node-dimensions@>=1.2.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.0.tgz"
- },
- "get-stdin": {
- "version": "4.0.1",
- "from": "get-stdin@>=4.0.1 <5.0.0",
- "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz"
- },
- "glob": {
- "version": "6.0.4",
- "from": "glob@>=6.0.1 <7.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz"
- },
- "glob-base": {
- "version": "0.3.0",
- "from": "glob-base@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz"
- },
- "glob-parent": {
- "version": "2.0.0",
- "from": "glob-parent@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz"
- },
- "glob-stream": {
- "version": "3.1.18",
- "from": "glob-stream@>=3.1.5 <4.0.0",
- "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-3.1.18.tgz",
- "dependencies": {
- "glob": {
- "version": "4.5.3",
- "from": "glob@>=4.3.1 <5.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz"
- },
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.1 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- }
- }
- },
- "glob-watcher": {
- "version": "0.0.6",
- "from": "glob-watcher@>=0.0.6 <0.0.7",
- "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-0.0.6.tgz"
- },
- "glob2base": {
- "version": "0.0.12",
- "from": "glob2base@>=0.0.12 <0.0.13",
- "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz"
- },
- "globals": {
- "version": "8.18.0",
- "from": "globals@>=8.3.0 <9.0.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-8.18.0.tgz"
- },
- "globby": {
- "version": "4.1.0",
- "from": "globby@>=4.0.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-4.1.0.tgz"
- },
- "globjoin": {
- "version": "0.1.4",
- "from": "globjoin@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz"
- },
- "globule": {
- "version": "0.1.0",
- "from": "globule@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/globule/-/globule-0.1.0.tgz",
- "dependencies": {
- "glob": {
- "version": "3.1.21",
- "from": "glob@>=3.1.21 <3.2.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz"
- },
- "graceful-fs": {
- "version": "1.2.3",
- "from": "graceful-fs@>=1.2.0 <1.3.0",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz"
- },
- "inherits": {
- "version": "1.0.2",
- "from": "inherits@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz"
- },
- "lodash": {
- "version": "1.0.2",
- "from": "lodash@>=1.0.1 <1.1.0",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz"
- },
- "minimatch": {
- "version": "0.2.14",
- "from": "minimatch@>=0.2.11 <0.3.0",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz"
- }
- }
- },
- "glogg": {
- "version": "1.0.0",
- "from": "glogg@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.0.tgz"
- },
- "graceful-fs": {
- "version": "4.1.4",
- "from": "graceful-fs@>=4.1.2 <5.0.0",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.4.tgz"
- },
- "graceful-readlink": {
- "version": "1.0.1",
- "from": "graceful-readlink@>=1.0.0",
- "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz"
- },
- "growl": {
- "version": "1.8.1",
- "from": "growl@1.8.1",
- "resolved": "https://registry.npmjs.org/growl/-/growl-1.8.1.tgz"
- },
- "gulp": {
- "version": "3.9.1",
- "from": "gulp@3.9.1",
- "resolved": "https://registry.npmjs.org/gulp/-/gulp-3.9.1.tgz"
- },
- "gulp-cached": {
- "version": "1.1.0",
- "from": "gulp-cached@1.1.0",
- "resolved": "https://registry.npmjs.org/gulp-cached/-/gulp-cached-1.1.0.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.17 <1.1.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.5.1",
- "from": "through2@>=0.5.1 <0.6.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz"
- },
- "xtend": {
- "version": "3.0.0",
- "from": "xtend@>=3.0.0 <3.1.0",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz"
- }
- }
- },
- "gulp-concat": {
- "version": "2.6.0",
- "from": "gulp-concat@2.6.0",
- "resolved": "https://registry.npmjs.org/gulp-concat/-/gulp-concat-2.6.0.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.3 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- }
- }
- },
- "gulp-declare": {
- "version": "0.3.0",
- "from": "gulp-declare@0.3.0",
- "resolved": "https://registry.npmjs.org/gulp-declare/-/gulp-declare-0.3.0.tgz"
- },
- "gulp-handlebars": {
- "version": "3.0.1",
- "from": "gulp-handlebars@3.0.1",
- "resolved": "https://registry.npmjs.org/gulp-handlebars/-/gulp-handlebars-3.0.1.tgz",
- "dependencies": {
- "handlebars": {
- "version": "2.0.0",
- "from": "handlebars@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-2.0.0.tgz"
- },
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "optimist": {
- "version": "0.3.7",
- "from": "optimist@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.1 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- },
- "wordwrap": {
- "version": "0.0.3",
- "from": "wordwrap@>=0.0.2 <0.1.0",
- "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz"
- }
- }
- },
- "gulp-less": {
- "version": "3.0.3",
- "from": "gulp-less@3.0.3",
- "resolved": "https://registry.npmjs.org/gulp-less/-/gulp-less-3.0.3.tgz",
- "dependencies": {
- "accord": {
- "version": "0.15.2",
- "from": "accord@>=0.15.2 <0.16.0",
- "resolved": "https://registry.npmjs.org/accord/-/accord-0.15.2.tgz"
- },
- "convert-source-map": {
- "version": "0.4.1",
- "from": "convert-source-map@>=0.4.1 <0.5.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-0.4.1.tgz"
- },
- "glob": {
- "version": "4.5.3",
- "from": "glob@>=4.0.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz"
- },
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "lodash": {
- "version": "3.10.1",
- "from": "lodash@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz"
- },
- "object-assign": {
- "version": "2.1.1",
- "from": "object-assign@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-2.1.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "source-map": {
- "version": "0.1.43",
- "from": "source-map@>=0.1.39 <0.2.0",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.3 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- },
- "vinyl-sourcemaps-apply": {
- "version": "0.1.4",
- "from": "vinyl-sourcemaps-apply@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.1.4.tgz"
- }
- }
- },
- "gulp-livereload": {
- "version": "3.8.1",
- "from": "gulp-livereload@3.8.1",
- "resolved": "https://registry.npmjs.org/gulp-livereload/-/gulp-livereload-3.8.1.tgz",
- "dependencies": {
- "ansi-regex": {
- "version": "0.2.1",
- "from": "ansi-regex@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz"
- },
- "ansi-styles": {
- "version": "1.1.0",
- "from": "ansi-styles@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz"
- },
- "chalk": {
- "version": "0.5.1",
- "from": "chalk@>=0.5.1 <0.6.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz"
- },
- "has-ansi": {
- "version": "0.1.0",
- "from": "has-ansi@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz"
- },
- "lodash.assign": {
- "version": "3.2.0",
- "from": "lodash.assign@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-3.2.0.tgz"
- },
- "lodash.keys": {
- "version": "3.1.2",
- "from": "lodash.keys@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz"
- },
- "strip-ansi": {
- "version": "0.3.0",
- "from": "strip-ansi@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz"
- },
- "supports-color": {
- "version": "0.2.0",
- "from": "supports-color@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz"
- }
- }
- },
- "gulp-postcss": {
- "version": "6.1.1",
- "from": "gulp-postcss@6.1.1",
- "resolved": "https://registry.npmjs.org/gulp-postcss/-/gulp-postcss-6.1.1.tgz"
- },
- "gulp-print": {
- "version": "2.0.1",
- "from": "gulp-print@2.0.1",
- "resolved": "https://registry.npmjs.org/gulp-print/-/gulp-print-2.0.1.tgz",
- "dependencies": {
- "map-stream": {
- "version": "0.0.6",
- "from": "map-stream@>=0.0.6 <0.1.0",
- "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.6.tgz"
- }
- }
- },
- "gulp-sourcemaps": {
- "version": "1.6.0",
- "from": "gulp-sourcemaps@1.6.0",
- "resolved": "https://registry.npmjs.org/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz",
- "dependencies": {
- "vinyl": {
- "version": "1.1.1",
- "from": "vinyl@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.1.1.tgz"
- }
- }
- },
- "gulp-stripbom": {
- "version": "1.0.4",
- "from": "gulp-stripbom@1.0.4",
- "resolved": "https://registry.npmjs.org/gulp-stripbom/-/gulp-stripbom-1.0.4.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.17 <1.1.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "strip-bom": {
- "version": "1.0.0",
- "from": "strip-bom@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz"
- },
- "through2": {
- "version": "0.5.1",
- "from": "through2@>=0.5.1 <0.6.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz"
- },
- "xtend": {
- "version": "3.0.0",
- "from": "xtend@>=3.0.0 <3.1.0",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz"
- }
- }
- },
- "gulp-util": {
- "version": "3.0.7",
- "from": "gulp-util@3.0.7",
- "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.7.tgz",
- "dependencies": {
- "object-assign": {
- "version": "3.0.0",
- "from": "object-assign@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz"
- }
- }
- },
- "gulp-watch": {
- "version": "4.3.5",
- "from": "gulp-watch@4.3.5",
- "resolved": "https://registry.npmjs.org/gulp-watch/-/gulp-watch-4.3.5.tgz",
- "dependencies": {
- "glob": {
- "version": "5.0.15",
- "from": "glob@>=5.0.13 <6.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz"
- }
- }
- },
- "gulp-wrap": {
- "version": "0.13.0",
- "from": "gulp-wrap@0.13.0",
- "resolved": "https://registry.npmjs.org/gulp-wrap/-/gulp-wrap-0.13.0.tgz"
- },
- "gulplog": {
- "version": "1.0.0",
- "from": "gulplog@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz"
- },
- "handlebars": {
- "version": "3.0.3",
- "from": "handlebars@3.0.3",
- "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-3.0.3.tgz",
- "dependencies": {
- "source-map": {
- "version": "0.1.43",
- "from": "source-map@>=0.1.40 <0.2.0",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz"
- }
- }
- },
- "has-ansi": {
- "version": "2.0.0",
- "from": "has-ansi@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz"
- },
- "has-flag": {
- "version": "1.0.0",
- "from": "has-flag@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz"
- },
- "has-gulplog": {
- "version": "0.1.0",
- "from": "has-gulplog@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/has-gulplog/-/has-gulplog-0.1.0.tgz"
- },
- "has-own": {
- "version": "1.0.0",
- "from": "has-own@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.0.tgz"
- },
- "history": {
- "version": "3.2.1",
- "from": "history@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/history/-/history-3.2.1.tgz"
- },
- "hoist-non-react-statics": {
- "version": "1.2.0",
- "from": "hoist-non-react-statics@>=1.0.3 <2.0.0",
- "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz"
- },
- "home-or-tmp": {
- "version": "1.0.0",
- "from": "home-or-tmp@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-1.0.0.tgz"
- },
- "hosted-git-info": {
- "version": "2.1.5",
- "from": "hosted-git-info@>=2.1.4 <3.0.0",
- "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.1.5.tgz"
- },
- "html-comment-regex": {
- "version": "1.1.0",
- "from": "html-comment-regex@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.0.tgz"
- },
- "html-tags": {
- "version": "1.1.1",
- "from": "html-tags@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-1.1.1.tgz"
- },
- "http-browserify": {
- "version": "1.7.0",
- "from": "http-browserify@>=1.3.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/http-browserify/-/http-browserify-1.7.0.tgz"
- },
- "http-errors": {
- "version": "1.3.1",
- "from": "http-errors@>=1.3.1 <1.4.0",
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz"
- },
- "https-browserify": {
- "version": "0.0.0",
- "from": "https-browserify@0.0.0",
- "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-0.0.0.tgz"
- },
- "iconv-lite": {
- "version": "0.4.13",
- "from": "iconv-lite@0.4.13",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz"
- },
- "icss-replace-symbols": {
- "version": "1.0.2",
- "from": "icss-replace-symbols@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz"
- },
- "ieee754": {
- "version": "1.1.6",
- "from": "ieee754@>=1.1.4 <2.0.0",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.6.tgz"
- },
- "ignore": {
- "version": "3.2.0",
- "from": "ignore@>=3.1.2 <4.0.0",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.2.0.tgz"
- },
- "image-size": {
- "version": "0.5.0",
- "from": "image-size@>=0.5.0 <0.6.0",
- "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.0.tgz",
- "optional": true
- },
- "imurmurhash": {
- "version": "0.1.4",
- "from": "imurmurhash@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz"
- },
- "indent-string": {
- "version": "2.1.0",
- "from": "indent-string@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz",
- "dependencies": {
- "repeating": {
- "version": "2.0.1",
- "from": "repeating@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz"
- }
- }
- },
- "indexes-of": {
- "version": "1.0.1",
- "from": "indexes-of@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz"
- },
- "indexof": {
- "version": "0.0.1",
- "from": "indexof@0.0.1",
- "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz"
- },
- "indx": {
- "version": "0.2.3",
- "from": "indx@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/indx/-/indx-0.2.3.tgz"
- },
- "inflight": {
- "version": "1.0.5",
- "from": "inflight@>=1.0.4 <2.0.0",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz"
- },
- "inherits": {
- "version": "2.0.1",
- "from": "inherits@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
- },
- "inquirer": {
- "version": "0.12.0",
- "from": "inquirer@>=0.12.0 <0.13.0",
- "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz"
- },
- "interpret": {
- "version": "1.0.1",
- "from": "interpret@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.0.1.tgz"
- },
- "invariant": {
- "version": "2.2.1",
- "from": "invariant@>=2.2.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.1.tgz"
- },
- "irregular-plurals": {
- "version": "1.2.0",
- "from": "irregular-plurals@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-1.2.0.tgz"
- },
- "is": {
- "version": "3.1.0",
- "from": "is@>=3.0.1 <4.0.0",
- "resolved": "https://registry.npmjs.org/is/-/is-3.1.0.tgz"
- },
- "is-absolute-url": {
- "version": "2.0.0",
- "from": "is-absolute-url@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.0.0.tgz"
- },
- "is-arrayish": {
- "version": "0.2.1",
- "from": "is-arrayish@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz"
- },
- "is-binary-path": {
- "version": "1.0.1",
- "from": "is-binary-path@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz"
- },
- "is-buffer": {
- "version": "1.1.3",
- "from": "is-buffer@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.3.tgz"
- },
- "is-builtin-module": {
- "version": "1.0.0",
- "from": "is-builtin-module@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz"
- },
- "is-dotfile": {
- "version": "1.0.2",
- "from": "is-dotfile@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.2.tgz"
- },
- "is-equal-shallow": {
- "version": "0.1.3",
- "from": "is-equal-shallow@>=0.1.3 <0.2.0",
- "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz"
- },
- "is-extendable": {
- "version": "0.1.1",
- "from": "is-extendable@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz"
- },
- "is-extglob": {
- "version": "1.0.0",
- "from": "is-extglob@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz"
- },
- "is-finite": {
- "version": "1.0.1",
- "from": "is-finite@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.1.tgz"
- },
- "is-fullwidth-code-point": {
- "version": "1.0.0",
- "from": "is-fullwidth-code-point@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz"
- },
- "is-glob": {
- "version": "2.0.1",
- "from": "is-glob@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz"
- },
- "is-my-json-valid": {
- "version": "2.15.0",
- "from": "is-my-json-valid@>=2.10.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz"
- },
- "is-number": {
- "version": "2.1.0",
- "from": "is-number@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz"
- },
- "is-path-cwd": {
- "version": "1.0.0",
- "from": "is-path-cwd@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz"
- },
- "is-path-in-cwd": {
- "version": "1.0.0",
- "from": "is-path-in-cwd@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz"
- },
- "is-path-inside": {
- "version": "1.0.0",
- "from": "is-path-inside@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.0.tgz"
- },
- "is-plain-obj": {
- "version": "1.1.0",
- "from": "is-plain-obj@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz"
- },
- "is-posix-bracket": {
- "version": "0.1.1",
- "from": "is-posix-bracket@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz"
- },
- "is-primitive": {
- "version": "2.0.0",
- "from": "is-primitive@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz"
- },
- "is-property": {
- "version": "1.0.2",
- "from": "is-property@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz"
- },
- "is-regexp": {
- "version": "1.0.0",
- "from": "is-regexp@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz"
- },
- "is-resolvable": {
- "version": "1.0.0",
- "from": "is-resolvable@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.0.0.tgz"
- },
- "is-stream": {
- "version": "1.1.0",
- "from": "is-stream@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz"
- },
- "is-supported-regexp-flag": {
- "version": "1.0.0",
- "from": "is-supported-regexp-flag@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.0.tgz"
- },
- "is-svg": {
- "version": "2.0.1",
- "from": "is-svg@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.0.1.tgz"
- },
- "is-utf8": {
- "version": "0.2.1",
- "from": "is-utf8@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz"
- },
- "isarray": {
- "version": "1.0.0",
- "from": "isarray@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
- },
- "isexe": {
- "version": "1.1.2",
- "from": "isexe@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz"
- },
- "isobject": {
- "version": "2.1.0",
- "from": "isobject@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz"
- },
- "isomorphic-fetch": {
- "version": "2.2.1",
- "from": "isomorphic-fetch@>=2.1.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz"
- },
- "isstream": {
- "version": "0.1.2",
- "from": "isstream@>=0.1.2 <0.2.0",
- "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz"
- },
- "jade": {
- "version": "0.26.3",
- "from": "jade@0.26.3",
- "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz",
- "dependencies": {
- "commander": {
- "version": "0.6.1",
- "from": "commander@0.6.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz"
- },
- "mkdirp": {
- "version": "0.3.0",
- "from": "mkdirp@0.3.0",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz"
- }
- }
- },
- "js-base64": {
- "version": "2.1.9",
- "from": "js-base64@>=2.1.9 <3.0.0",
- "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.1.9.tgz"
- },
- "js-stylesheet": {
- "version": "0.0.1",
- "from": "js-stylesheet@>=0.0.1 <0.0.2",
- "resolved": "https://registry.npmjs.org/js-stylesheet/-/js-stylesheet-0.0.1.tgz"
- },
- "js-tokens": {
- "version": "1.0.3",
- "from": "js-tokens@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-1.0.3.tgz"
- },
- "js-yaml": {
- "version": "3.6.1",
- "from": "js-yaml@>=3.6.1 <3.7.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz"
- },
- "jsesc": {
- "version": "0.5.0",
- "from": "jsesc@>=0.5.0 <0.6.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz"
- },
- "json-stable-stringify": {
- "version": "1.0.1",
- "from": "json-stable-stringify@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz"
- },
- "json-stringify-safe": {
- "version": "5.0.1",
- "from": "json-stringify-safe@>=5.0.1 <6.0.0",
- "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
- },
- "json5": {
- "version": "0.4.0",
- "from": "json5@>=0.4.0 <0.5.0",
- "resolved": "https://registry.npmjs.org/json5/-/json5-0.4.0.tgz"
- },
- "jsonfilter": {
- "version": "1.1.2",
- "from": "jsonfilter@>=1.1.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/jsonfilter/-/jsonfilter-1.1.2.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "stream-combiner": {
- "version": "0.2.2",
- "from": "stream-combiner@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.3 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- }
- }
- },
- "jsonify": {
- "version": "0.0.0",
- "from": "jsonify@>=0.0.0 <0.1.0",
- "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz"
- },
- "jsonparse": {
- "version": "0.0.5",
- "from": "jsonparse@0.0.5",
- "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-0.0.5.tgz"
- },
- "jsonpointer": {
- "version": "4.0.0",
- "from": "jsonpointer@>=4.0.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.0.tgz"
- },
- "JSONStream": {
- "version": "0.8.4",
- "from": "JSONStream@>=0.8.4 <0.9.0",
- "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-0.8.4.tgz"
- },
- "jsx-ast-utils": {
- "version": "1.3.1",
- "from": "jsx-ast-utils@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-1.3.1.tgz"
- },
- "kind-of": {
- "version": "3.0.3",
- "from": "kind-of@>=3.0.2 <4.0.0",
- "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.0.3.tgz"
- },
- "known-css-properties": {
- "version": "0.0.4",
- "from": "known-css-properties@>=0.0.4 <0.0.5",
- "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.0.4.tgz"
- },
- "lazy-cache": {
- "version": "1.0.4",
- "from": "lazy-cache@>=1.0.3 <2.0.0",
- "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz"
- },
- "ldjson-stream": {
- "version": "1.2.1",
- "from": "ldjson-stream@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/ldjson-stream/-/ldjson-stream-1.2.1.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.1 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- }
- }
- },
- "less": {
- "version": "2.7.1",
- "from": "less@>=2.6.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/less/-/less-2.7.1.tgz"
- },
- "levn": {
- "version": "0.3.0",
- "from": "levn@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz"
- },
- "liftoff": {
- "version": "2.2.1",
- "from": "liftoff@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.2.1.tgz"
- },
- "livereload-js": {
- "version": "2.2.2",
- "from": "livereload-js@>=2.2.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.2.2.tgz"
- },
- "load-json-file": {
- "version": "1.1.0",
- "from": "load-json-file@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz"
- },
- "loader-utils": {
- "version": "0.2.15",
- "from": "loader-utils@>=0.2.11 <0.3.0",
- "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.15.tgz",
- "dependencies": {
- "json5": {
- "version": "0.5.0",
- "from": "json5@>=0.5.0 <0.6.0",
- "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.0.tgz"
- }
- }
- },
- "lodash": {
- "version": "4.17.4",
- "from": "lodash@4.17.4",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz"
- },
- "lodash-es": {
- "version": "4.13.1",
- "from": "lodash-es@>=4.2.1 <5.0.0",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.13.1.tgz"
- },
- "lodash._arraycopy": {
- "version": "3.0.0",
- "from": "lodash._arraycopy@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz"
- },
- "lodash._arrayeach": {
- "version": "3.0.0",
- "from": "lodash._arrayeach@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz"
- },
- "lodash._baseassign": {
- "version": "3.2.0",
- "from": "lodash._baseassign@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz",
- "dependencies": {
- "lodash.keys": {
- "version": "3.1.2",
- "from": "lodash.keys@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz"
- }
- }
- },
- "lodash._baseclone": {
- "version": "3.3.0",
- "from": "lodash._baseclone@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._baseclone/-/lodash._baseclone-3.3.0.tgz",
- "dependencies": {
- "lodash.keys": {
- "version": "3.1.2",
- "from": "lodash.keys@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz"
- }
- }
- },
- "lodash._basecopy": {
- "version": "3.0.1",
- "from": "lodash._basecopy@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz"
- },
- "lodash._basefor": {
- "version": "3.0.3",
- "from": "lodash._basefor@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._basefor/-/lodash._basefor-3.0.3.tgz"
- },
- "lodash._basevalues": {
- "version": "3.0.0",
- "from": "lodash._basevalues@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz"
- },
- "lodash._bindcallback": {
- "version": "3.0.1",
- "from": "lodash._bindcallback@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz"
- },
- "lodash._createassigner": {
- "version": "3.1.1",
- "from": "lodash._createassigner@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz"
- },
- "lodash._createcompounder": {
- "version": "3.0.0",
- "from": "lodash._createcompounder@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._createcompounder/-/lodash._createcompounder-3.0.0.tgz"
- },
- "lodash._getnative": {
- "version": "3.9.1",
- "from": "lodash._getnative@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz"
- },
- "lodash._isiterateecall": {
- "version": "3.0.9",
- "from": "lodash._isiterateecall@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz"
- },
- "lodash._isnative": {
- "version": "2.4.1",
- "from": "lodash._isnative@>=2.4.1 <2.5.0",
- "resolved": "https://registry.npmjs.org/lodash._isnative/-/lodash._isnative-2.4.1.tgz"
- },
- "lodash._objecttypes": {
- "version": "2.4.1",
- "from": "lodash._objecttypes@>=2.4.1 <2.5.0",
- "resolved": "https://registry.npmjs.org/lodash._objecttypes/-/lodash._objecttypes-2.4.1.tgz"
- },
- "lodash._reescape": {
- "version": "3.0.0",
- "from": "lodash._reescape@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._reescape/-/lodash._reescape-3.0.0.tgz"
- },
- "lodash._reevaluate": {
- "version": "3.0.0",
- "from": "lodash._reevaluate@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz"
- },
- "lodash._reinterpolate": {
- "version": "3.0.0",
- "from": "lodash._reinterpolate@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz"
- },
- "lodash._root": {
- "version": "3.0.1",
- "from": "lodash._root@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz"
- },
- "lodash._shimkeys": {
- "version": "2.4.1",
- "from": "lodash._shimkeys@>=2.4.1 <2.5.0",
- "resolved": "https://registry.npmjs.org/lodash._shimkeys/-/lodash._shimkeys-2.4.1.tgz"
- },
- "lodash.camelcase": {
- "version": "3.0.1",
- "from": "lodash.camelcase@>=3.0.1 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-3.0.1.tgz"
- },
- "lodash.clone": {
- "version": "3.0.3",
- "from": "lodash.clone@>=3.0.0 <3.1.0-0",
- "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-3.0.3.tgz"
- },
- "lodash.deburr": {
- "version": "3.2.0",
- "from": "lodash.deburr@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-3.2.0.tgz"
- },
- "lodash.defaults": {
- "version": "2.4.1",
- "from": "lodash.defaults@>=2.4.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-2.4.1.tgz",
- "dependencies": {
- "lodash.keys": {
- "version": "2.4.1",
- "from": "lodash.keys@>=2.4.1 <2.5.0",
- "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-2.4.1.tgz"
- }
- }
- },
- "lodash.escape": {
- "version": "3.2.0",
- "from": "lodash.escape@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz"
- },
- "lodash.isarguments": {
- "version": "3.0.8",
- "from": "lodash.isarguments@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.0.8.tgz"
- },
- "lodash.isarray": {
- "version": "3.0.4",
- "from": "lodash.isarray@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz"
- },
- "lodash.isobject": {
- "version": "2.4.1",
- "from": "lodash.isobject@>=2.4.1 <2.5.0",
- "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-2.4.1.tgz"
- },
- "lodash.keys": {
- "version": "3.1.2",
- "from": "lodash.keys@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz"
- },
- "lodash.pickby": {
- "version": "4.6.0",
- "from": "lodash.pickby@>=4.6.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz"
- },
- "lodash.restparam": {
- "version": "3.6.1",
- "from": "lodash.restparam@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz"
- },
- "lodash.template": {
- "version": "3.6.2",
- "from": "lodash.template@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-3.6.2.tgz",
- "dependencies": {
- "lodash._basetostring": {
- "version": "3.0.1",
- "from": "lodash._basetostring@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz"
- },
- "lodash.keys": {
- "version": "3.1.2",
- "from": "lodash.keys@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz"
- }
- }
- },
- "lodash.templatesettings": {
- "version": "3.1.1",
- "from": "lodash.templatesettings@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz"
- },
- "lodash.words": {
- "version": "3.2.0",
- "from": "lodash.words@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/lodash.words/-/lodash.words-3.2.0.tgz"
- },
- "log-symbols": {
- "version": "1.0.2",
- "from": "log-symbols@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz"
- },
- "longest": {
- "version": "1.0.1",
- "from": "longest@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz"
- },
- "loose-envify": {
- "version": "1.2.0",
- "from": "loose-envify@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.2.0.tgz"
- },
- "loud-rejection": {
- "version": "1.3.0",
- "from": "loud-rejection@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.3.0.tgz"
- },
- "lru-cache": {
- "version": "2.7.3",
- "from": "lru-cache@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
- },
- "map-obj": {
- "version": "1.0.1",
- "from": "map-obj@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz"
- },
- "map-stream": {
- "version": "0.1.0",
- "from": "map-stream@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz"
- },
- "media-typer": {
- "version": "0.3.0",
- "from": "media-typer@0.3.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
- },
- "memory-fs": {
- "version": "0.3.0",
- "from": "memory-fs@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.3.0.tgz"
- },
- "meow": {
- "version": "3.7.0",
- "from": "meow@>=3.3.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz"
- },
- "micromatch": {
- "version": "2.3.8",
- "from": "micromatch@>=2.1.5 <3.0.0",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.8.tgz"
- },
- "mime": {
- "version": "1.3.4",
- "from": "mime@>=1.2.11 <2.0.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz",
- "optional": true
- },
- "mime-db": {
- "version": "1.23.0",
- "from": "mime-db@>=1.23.0 <1.24.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
- },
- "mime-types": {
- "version": "2.1.11",
- "from": "mime-types@>=2.1.11 <2.2.0",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz"
- },
- "mini-lr": {
- "version": "0.1.9",
- "from": "mini-lr@>=0.1.8 <0.2.0",
- "resolved": "https://registry.npmjs.org/mini-lr/-/mini-lr-0.1.9.tgz"
- },
- "minimatch": {
- "version": "2.0.10",
- "from": "minimatch@>=2.0.3 <3.0.0",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz"
- },
- "minimist": {
- "version": "1.2.0",
- "from": "minimist@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz"
- },
- "mkdirp": {
- "version": "0.5.1",
- "from": "mkdirp@>=0.5.1 <0.6.0",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
- "dependencies": {
- "minimist": {
- "version": "0.0.8",
- "from": "minimist@0.0.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
- }
- }
- },
- "mocha": {
- "version": "2.4.5",
- "from": "mocha@>=2.2.5 <3.0.0",
- "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.4.5.tgz",
- "dependencies": {
- "commander": {
- "version": "2.3.0",
- "from": "commander@2.3.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz"
- },
- "escape-string-regexp": {
- "version": "1.0.2",
- "from": "escape-string-regexp@1.0.2",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz"
- },
- "glob": {
- "version": "3.2.3",
- "from": "glob@3.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz"
- },
- "graceful-fs": {
- "version": "2.0.3",
- "from": "graceful-fs@>=2.0.0 <2.1.0",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz"
- },
- "minimatch": {
- "version": "0.2.14",
- "from": "minimatch@>=0.2.11 <0.3.0",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz"
- },
- "supports-color": {
- "version": "1.2.0",
- "from": "supports-color@1.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.2.0.tgz"
- }
- }
- },
- "moment": {
- "version": "2.17.1",
- "from": "moment@latest",
- "resolved": "https://registry.npmjs.org/moment/-/moment-2.17.1.tgz"
- },
- "mout": {
- "version": "1.0.0",
- "from": "mout@>=0.9.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/mout/-/mout-1.0.0.tgz"
- },
- "ms": {
- "version": "0.7.1",
- "from": "ms@0.7.1",
- "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
- },
- "multimatch": {
- "version": "2.1.0",
- "from": "multimatch@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz",
- "dependencies": {
- "minimatch": {
- "version": "3.0.3",
- "from": "minimatch@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz"
- }
- }
- },
- "multipipe": {
- "version": "0.1.2",
- "from": "multipipe@>=0.1.2 <0.2.0",
- "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz"
- },
- "mute-stream": {
- "version": "0.0.5",
- "from": "mute-stream@0.0.5",
- "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz"
- },
- "new-from": {
- "version": "0.0.3",
- "from": "new-from@0.0.3",
- "resolved": "https://registry.npmjs.org/new-from/-/new-from-0.0.3.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.1.14",
- "from": "readable-stream@>=1.1.8 <1.2.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz"
- }
- }
- },
- "node-fetch": {
- "version": "1.6.3",
- "from": "node-fetch@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.6.3.tgz"
- },
- "node-libs-browser": {
- "version": "0.5.3",
- "from": "node-libs-browser@>=0.4.0 <=0.6.0",
- "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-0.5.3.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.1.14",
- "from": "readable-stream@>=1.1.13 <2.0.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz"
- }
- }
- },
- "node.extend": {
- "version": "1.1.5",
- "from": "node.extend@>=1.1.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.1.5.tgz"
- },
- "normalize-package-data": {
- "version": "2.3.5",
- "from": "normalize-package-data@>=2.3.4 <3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.3.5.tgz"
- },
- "normalize-path": {
- "version": "2.0.1",
- "from": "normalize-path@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.0.1.tgz"
- },
- "normalize-range": {
- "version": "0.1.2",
- "from": "normalize-range@>=0.1.2 <0.2.0",
- "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz"
- },
- "normalize-selector": {
- "version": "0.2.0",
- "from": "normalize-selector@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/normalize-selector/-/normalize-selector-0.2.0.tgz"
- },
- "normalize-url": {
- "version": "1.5.3",
- "from": "normalize-url@>=1.4.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.5.3.tgz"
- },
- "normalize.css": {
- "version": "5.0.0",
- "from": "normalize.css@5.0.0",
- "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-5.0.0.tgz"
- },
- "npm-path": {
- "version": "1.1.0",
- "from": "npm-path@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-1.1.0.tgz"
- },
- "npm-run": {
- "version": "2.0.0",
- "from": "npm-run@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/npm-run/-/npm-run-2.0.0.tgz"
- },
- "npm-which": {
- "version": "2.0.0",
- "from": "npm-which@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-2.0.0.tgz"
- },
- "nsdeclare": {
- "version": "0.1.0",
- "from": "nsdeclare@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/nsdeclare/-/nsdeclare-0.1.0.tgz"
- },
- "num2fraction": {
- "version": "1.2.2",
- "from": "num2fraction@>=1.2.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz"
- },
- "number-is-nan": {
- "version": "1.0.0",
- "from": "number-is-nan@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.0.tgz"
- },
- "object-assign": {
- "version": "4.1.0",
- "from": "object-assign@>=4.0.1 <5.0.0",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz"
- },
- "object-keys": {
- "version": "0.4.0",
- "from": "object-keys@>=0.4.0 <0.5.0",
- "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz"
- },
- "object.omit": {
- "version": "2.0.0",
- "from": "object.omit@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.0.tgz"
- },
- "on-finished": {
- "version": "2.3.0",
- "from": "on-finished@>=2.3.0 <2.4.0",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz"
- },
- "once": {
- "version": "1.3.3",
- "from": "once@>=1.3.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz"
- },
- "onecolor": {
- "version": "3.0.4",
- "from": "onecolor@>=3.0.4 <4.0.0",
- "resolved": "https://registry.npmjs.org/onecolor/-/onecolor-3.0.4.tgz"
- },
- "onetime": {
- "version": "1.1.0",
- "from": "onetime@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz"
- },
- "optimist": {
- "version": "0.6.1",
- "from": "optimist@>=0.6.1 <0.7.0",
- "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
- "dependencies": {
- "minimist": {
- "version": "0.0.10",
- "from": "minimist@>=0.0.1 <0.1.0",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz"
- },
- "wordwrap": {
- "version": "0.0.3",
- "from": "wordwrap@>=0.0.2 <0.1.0",
- "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz"
- }
- }
- },
- "optionator": {
- "version": "0.8.2",
- "from": "optionator@>=0.8.1 <0.9.0",
- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz"
- },
- "orchestrator": {
- "version": "0.3.7",
- "from": "orchestrator@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/orchestrator/-/orchestrator-0.3.7.tgz"
- },
- "ordered-read-streams": {
- "version": "0.1.0",
- "from": "ordered-read-streams@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz"
- },
- "os-browserify": {
- "version": "0.1.2",
- "from": "os-browserify@>=0.1.2 <0.2.0",
- "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.1.2.tgz"
- },
- "os-homedir": {
- "version": "1.0.1",
- "from": "os-homedir@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.1.tgz"
- },
- "os-shim": {
- "version": "0.1.3",
- "from": "os-shim@>=0.1.2 <0.2.0",
- "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz"
- },
- "os-tmpdir": {
- "version": "1.0.1",
- "from": "os-tmpdir@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.1.tgz"
- },
- "pako": {
- "version": "0.2.8",
- "from": "pako@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.8.tgz"
- },
- "parse-glob": {
- "version": "3.0.4",
- "from": "parse-glob@>=3.0.4 <4.0.0",
- "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz"
- },
- "parse-json": {
- "version": "2.2.0",
- "from": "parse-json@>=2.2.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz"
- },
- "parseurl": {
- "version": "1.3.1",
- "from": "parseurl@>=1.3.0 <1.4.0",
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
- },
- "path-browserify": {
- "version": "0.0.0",
- "from": "path-browserify@0.0.0",
- "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz"
- },
- "path-exists": {
- "version": "1.0.0",
- "from": "path-exists@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-1.0.0.tgz"
- },
- "path-is-absolute": {
- "version": "1.0.0",
- "from": "path-is-absolute@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
- },
- "path-is-inside": {
- "version": "1.0.1",
- "from": "path-is-inside@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.1.tgz"
- },
- "path-type": {
- "version": "1.1.0",
- "from": "path-type@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz"
- },
- "pause-stream": {
- "version": "0.0.11",
- "from": "pause-stream@0.0.11",
- "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz"
- },
- "pbkdf2-compat": {
- "version": "2.0.1",
- "from": "pbkdf2-compat@2.0.1",
- "resolved": "https://registry.npmjs.org/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz"
- },
- "pify": {
- "version": "2.3.0",
- "from": "pify@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
- },
- "pinkie": {
- "version": "2.0.4",
- "from": "pinkie@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz"
- },
- "pinkie-promise": {
- "version": "2.0.1",
- "from": "pinkie-promise@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz"
- },
- "pipetteur": {
- "version": "2.0.3",
- "from": "pipetteur@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/pipetteur/-/pipetteur-2.0.3.tgz"
- },
- "plur": {
- "version": "2.1.2",
- "from": "plur@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/plur/-/plur-2.1.2.tgz"
- },
- "pluralize": {
- "version": "1.2.1",
- "from": "pluralize@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz"
- },
- "postcss": {
- "version": "5.0.21",
- "from": "postcss@>=5.0.19 <6.0.0",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.0.21.tgz"
- },
- "postcss-calc": {
- "version": "5.2.1",
- "from": "postcss-calc@>=5.2.0 <6.0.0",
- "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-5.2.1.tgz"
- },
- "postcss-colormin": {
- "version": "2.2.0",
- "from": "postcss-colormin@>=2.1.8 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-2.2.0.tgz"
- },
- "postcss-convert-values": {
- "version": "2.4.0",
- "from": "postcss-convert-values@>=2.3.4 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-2.4.0.tgz"
- },
- "postcss-discard-comments": {
- "version": "2.0.4",
- "from": "postcss-discard-comments@>=2.0.4 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz"
- },
- "postcss-discard-duplicates": {
- "version": "2.0.1",
- "from": "postcss-discard-duplicates@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-2.0.1.tgz"
- },
- "postcss-discard-empty": {
- "version": "2.1.0",
- "from": "postcss-discard-empty@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz"
- },
- "postcss-discard-overridden": {
- "version": "0.1.1",
- "from": "postcss-discard-overridden@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz"
- },
- "postcss-discard-unused": {
- "version": "2.2.1",
- "from": "postcss-discard-unused@>=2.2.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-2.2.1.tgz"
- },
- "postcss-filter-plugins": {
- "version": "2.0.0",
- "from": "postcss-filter-plugins@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-filter-plugins/-/postcss-filter-plugins-2.0.0.tgz"
- },
- "postcss-less": {
- "version": "0.14.0",
- "from": "postcss-less@>=0.14.0 <0.15.0",
- "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-0.14.0.tgz"
- },
- "postcss-loader": {
- "version": "0.9.1",
- "from": "postcss-loader@0.9.1",
- "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-0.9.1.tgz"
- },
- "postcss-media-query-parser": {
- "version": "0.2.1",
- "from": "postcss-media-query-parser@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.1.tgz"
- },
- "postcss-merge-idents": {
- "version": "2.1.6",
- "from": "postcss-merge-idents@>=2.1.5 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-2.1.6.tgz"
- },
- "postcss-merge-longhand": {
- "version": "2.0.1",
- "from": "postcss-merge-longhand@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-2.0.1.tgz"
- },
- "postcss-merge-rules": {
- "version": "2.0.9",
- "from": "postcss-merge-rules@>=2.0.3 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-2.0.9.tgz"
- },
- "postcss-message-helpers": {
- "version": "2.0.0",
- "from": "postcss-message-helpers@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz"
- },
- "postcss-minify-font-values": {
- "version": "1.0.5",
- "from": "postcss-minify-font-values@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz"
- },
- "postcss-minify-gradients": {
- "version": "1.0.3",
- "from": "postcss-minify-gradients@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-1.0.3.tgz"
- },
- "postcss-minify-params": {
- "version": "1.0.4",
- "from": "postcss-minify-params@>=1.0.4 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-1.0.4.tgz"
- },
- "postcss-minify-selectors": {
- "version": "2.0.5",
- "from": "postcss-minify-selectors@>=2.0.4 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-2.0.5.tgz"
- },
- "postcss-modules-extract-imports": {
- "version": "1.0.1",
- "from": "postcss-modules-extract-imports@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.0.1.tgz"
- },
- "postcss-modules-local-by-default": {
- "version": "1.1.0",
- "from": "postcss-modules-local-by-default@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.1.0.tgz"
- },
- "postcss-modules-scope": {
- "version": "1.0.1",
- "from": "postcss-modules-scope@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.0.1.tgz"
- },
- "postcss-modules-values": {
- "version": "1.1.3",
- "from": "postcss-modules-values@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.1.3.tgz"
- },
- "postcss-nested": {
- "version": "1.0.0",
- "from": "postcss-nested@1.0.0",
- "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-1.0.0.tgz"
- },
- "postcss-normalize-charset": {
- "version": "1.1.0",
- "from": "postcss-normalize-charset@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-1.1.0.tgz"
- },
- "postcss-normalize-url": {
- "version": "3.0.7",
- "from": "postcss-normalize-url@>=3.0.7 <4.0.0",
- "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-3.0.7.tgz"
- },
- "postcss-ordered-values": {
- "version": "2.2.1",
- "from": "postcss-ordered-values@>=2.1.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-2.2.1.tgz"
- },
- "postcss-reduce-idents": {
- "version": "2.3.0",
- "from": "postcss-reduce-idents@>=2.2.2 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-2.3.0.tgz"
- },
- "postcss-reduce-initial": {
- "version": "1.0.0",
- "from": "postcss-reduce-initial@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-1.0.0.tgz"
- },
- "postcss-reduce-transforms": {
- "version": "1.0.3",
- "from": "postcss-reduce-transforms@>=1.0.3 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.3.tgz"
- },
- "postcss-reporter": {
- "version": "1.4.1",
- "from": "postcss-reporter@>=1.3.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-1.4.1.tgz"
- },
- "postcss-resolve-nested-selector": {
- "version": "0.1.1",
- "from": "postcss-resolve-nested-selector@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz"
- },
- "postcss-scss": {
- "version": "0.3.0",
- "from": "postcss-scss@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-0.3.0.tgz",
- "dependencies": {
- "postcss": {
- "version": "5.2.0",
- "from": "postcss@>=5.2.0 <6.0.0",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.0.tgz"
- }
- }
- },
- "postcss-selector-parser": {
- "version": "2.1.0",
- "from": "postcss-selector-parser@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.1.0.tgz"
- },
- "postcss-simple-vars": {
- "version": "3.0.0",
- "from": "postcss-simple-vars@3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-3.0.0.tgz"
- },
- "postcss-svgo": {
- "version": "2.1.3",
- "from": "postcss-svgo@>=2.1.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-2.1.3.tgz"
- },
- "postcss-unique-selectors": {
- "version": "2.0.2",
- "from": "postcss-unique-selectors@>=2.0.2 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz"
- },
- "postcss-value-parser": {
- "version": "3.3.0",
- "from": "postcss-value-parser@>=3.2.3 <4.0.0",
- "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz"
- },
- "postcss-zindex": {
- "version": "2.1.1",
- "from": "postcss-zindex@>=2.0.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-2.1.1.tgz"
- },
- "prelude-ls": {
- "version": "1.1.2",
- "from": "prelude-ls@>=1.1.2 <1.2.0",
- "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz"
- },
- "prepend-http": {
- "version": "1.0.4",
- "from": "prepend-http@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz"
- },
- "preserve": {
- "version": "0.2.0",
- "from": "preserve@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz"
- },
- "pretty-hrtime": {
- "version": "1.0.2",
- "from": "pretty-hrtime@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.2.tgz"
- },
- "private": {
- "version": "0.1.6",
- "from": "private@>=0.1.6 <0.2.0",
- "resolved": "https://registry.npmjs.org/private/-/private-0.1.6.tgz"
- },
- "process": {
- "version": "0.11.3",
- "from": "process@>=0.11.0 <0.12.0",
- "resolved": "https://registry.npmjs.org/process/-/process-0.11.3.tgz"
- },
- "process-nextick-args": {
- "version": "1.0.7",
- "from": "process-nextick-args@>=1.0.6 <1.1.0",
- "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
- },
- "progress": {
- "version": "1.1.8",
- "from": "progress@>=1.1.8 <2.0.0",
- "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz"
- },
- "promise": {
- "version": "7.1.1",
- "from": "promise@>=7.1.1 <8.0.0",
- "resolved": "https://registry.npmjs.org/promise/-/promise-7.1.1.tgz"
- },
- "protochain": {
- "version": "1.0.3",
- "from": "protochain@>=1.0.3 <2.0.0",
- "resolved": "https://registry.npmjs.org/protochain/-/protochain-1.0.3.tgz"
- },
- "prr": {
- "version": "0.0.0",
- "from": "prr@>=0.0.0 <0.1.0",
- "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz"
- },
- "punycode": {
- "version": "1.4.1",
- "from": "punycode@>=1.4.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz"
- },
- "q": {
- "version": "1.4.1",
- "from": "q@>=1.1.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/q/-/q-1.4.1.tgz"
- },
- "qs": {
- "version": "2.2.5",
- "from": "qs@>=2.2.3 <2.3.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-2.2.5.tgz"
- },
- "query-string": {
- "version": "4.2.2",
- "from": "query-string@>=4.1.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz"
- },
- "querystring": {
- "version": "0.2.0",
- "from": "querystring@0.2.0",
- "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz"
- },
- "querystring-es3": {
- "version": "0.2.1",
- "from": "querystring-es3@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz"
- },
- "randomatic": {
- "version": "1.1.5",
- "from": "randomatic@>=1.1.3 <2.0.0",
- "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.5.tgz"
- },
- "raven-js": {
- "version": "3.9.2",
- "from": "raven-js@>=3.1.1 <4.0.0",
- "resolved": "https://registry.npmjs.org/raven-js/-/raven-js-3.9.2.tgz"
- },
- "raw-body": {
- "version": "2.1.6",
- "from": "raw-body@>=2.1.5 <2.2.0",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.6.tgz",
- "dependencies": {
- "bytes": {
- "version": "2.3.0",
- "from": "bytes@2.3.0",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz"
- }
- }
- },
- "react": {
- "version": "15.4.2",
- "from": "react@15.4.2",
- "resolved": "https://registry.npmjs.org/react/-/react-15.4.2.tgz"
- },
- "react-addons-shallow-compare": {
- "version": "15.4.2",
- "from": "react-addons-shallow-compare@15.4.2",
- "resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.4.2.tgz"
- },
- "react-async-script": {
- "version": "0.5.1",
- "from": "react-async-script@>=0.5.0 <0.6.0",
- "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-0.5.1.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "5.8.38",
- "from": "babel-runtime@>=5.8.0 <6.0.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz"
- },
- "core-js": {
- "version": "1.2.7",
- "from": "core-js@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz"
- }
- }
- },
- "react-autosuggest": {
- "version": "8.0.0",
- "from": "react-autosuggest@8.0.0",
- "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-8.0.0.tgz"
- },
- "react-autowhatever": {
- "version": "7.0.0",
- "from": "react-autowhatever@>=7.0.0 <8.0.0",
- "resolved": "https://registry.npmjs.org/react-autowhatever/-/react-autowhatever-7.0.0.tgz"
- },
- "react-dnd": {
- "version": "2.1.4",
- "from": "react-dnd@2.1.4",
- "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.1.4.tgz"
- },
- "react-dnd-html5-backend": {
- "version": "2.1.2",
- "from": "react-dnd-html5-backend@2.1.2",
- "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.1.2.tgz"
- },
- "react-document-title": {
- "version": "2.0.2",
- "from": "react-document-title@2.0.2",
- "resolved": "https://registry.npmjs.org/react-document-title/-/react-document-title-2.0.2.tgz"
- },
- "react-dom": {
- "version": "15.4.2",
- "from": "react-dom@15.4.2",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.4.2.tgz"
- },
- "react-google-recaptcha": {
- "version": "0.5.4",
- "from": "react-google-recaptcha@0.5.4",
- "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-0.5.4.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "5.8.38",
- "from": "babel-runtime@>=5.8.0 <6.0.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz"
- },
- "core-js": {
- "version": "1.2.7",
- "from": "core-js@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz"
- }
- }
- },
- "react-lazyload": {
- "version": "2.2.0",
- "from": "react-lazyload@2.2.0",
- "resolved": "https://registry.npmjs.org/react-lazyload/-/react-lazyload-2.2.0.tgz"
- },
- "react-measure": {
- "version": "1.4.5",
- "from": "react-measure@1.4.5",
- "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-1.4.5.tgz"
- },
- "react-portal": {
- "version": "3.0.0",
- "from": "react-portal@3.0.0",
- "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-3.0.0.tgz"
- },
- "react-redux": {
- "version": "5.0.2",
- "from": "react-redux@5.0.2",
- "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.2.tgz"
- },
- "react-router": {
- "version": "3.0.2",
- "from": "react-router@3.0.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-3.0.2.tgz"
- },
- "react-router-redux": {
- "version": "4.0.7",
- "from": "react-router-redux@4.0.7",
- "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.7.tgz"
- },
- "react-side-effect": {
- "version": "1.1.0",
- "from": "react-side-effect@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.0.tgz"
- },
- "react-slider": {
- "version": "0.7.0",
- "from": "react-slider@0.7.0",
- "resolved": "https://registry.npmjs.org/react-slider/-/react-slider-0.7.0.tgz"
- },
- "react-tabs": {
- "version": "0.8.2",
- "from": "react-tabs@0.8.2",
- "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-0.8.2.tgz"
- },
- "react-tag-autocomplete": {
- "version": "5.1.0",
- "from": "react-tag-autocomplete@5.1.0",
- "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-5.1.0.tgz"
- },
- "react-tether": {
- "version": "0.5.5",
- "from": "react-tether@0.5.5",
- "resolved": "https://registry.npmjs.org/react-tether/-/react-tether-0.5.5.tgz"
- },
- "react-themeable": {
- "version": "1.1.0",
- "from": "react-themeable@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz",
- "dependencies": {
- "object-assign": {
- "version": "3.0.0",
- "from": "object-assign@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz"
- }
- }
- },
- "react-virtualized": {
- "version": "8.11.3",
- "from": "react-virtualized@latest",
- "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-8.11.3.tgz",
- "dependencies": {
- "babel-runtime": {
- "version": "6.22.0",
- "from": "babel-runtime@>=6.11.6 <7.0.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz"
- },
- "js-tokens": {
- "version": "3.0.0",
- "from": "js-tokens@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.0.tgz"
- },
- "loose-envify": {
- "version": "1.3.1",
- "from": "loose-envify@>=1.3.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz"
- },
- "regenerator-runtime": {
- "version": "0.10.1",
- "from": "regenerator-runtime@>=0.10.0 <0.11.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz"
- }
- }
- },
- "read-file-stdin": {
- "version": "0.2.1",
- "from": "read-file-stdin@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/read-file-stdin/-/read-file-stdin-0.2.1.tgz"
- },
- "read-pkg": {
- "version": "1.1.0",
- "from": "read-pkg@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz"
- },
- "read-pkg-up": {
- "version": "1.0.1",
- "from": "read-pkg-up@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz"
- },
- "readable-stream": {
- "version": "2.0.6",
- "from": "readable-stream@>=2.0.0 <2.1.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz"
- },
- "readdirp": {
- "version": "2.0.0",
- "from": "readdirp@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.0.0.tgz"
- },
- "readline2": {
- "version": "1.0.1",
- "from": "readline2@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz"
- },
- "rechoir": {
- "version": "0.6.2",
- "from": "rechoir@>=0.6.2 <0.7.0",
- "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz"
- },
- "redent": {
- "version": "1.0.0",
- "from": "redent@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz"
- },
- "reduce-css-calc": {
- "version": "1.2.4",
- "from": "reduce-css-calc@>=1.2.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.2.4.tgz",
- "dependencies": {
- "balanced-match": {
- "version": "0.1.0",
- "from": "balanced-match@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz"
- }
- }
- },
- "reduce-function-call": {
- "version": "1.0.1",
- "from": "reduce-function-call@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.1.tgz",
- "dependencies": {
- "balanced-match": {
- "version": "0.1.0",
- "from": "balanced-match@~0.1.0",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz"
- }
- }
- },
- "reduce-reducers": {
- "version": "0.1.2",
- "from": "reduce-reducers@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/reduce-reducers/-/reduce-reducers-0.1.2.tgz"
- },
- "redux": {
- "version": "3.6.0",
- "from": "redux@3.6.0",
- "resolved": "https://registry.npmjs.org/redux/-/redux-3.6.0.tgz"
- },
- "redux-actions": {
- "version": "1.2.0",
- "from": "redux-actions@1.2.0",
- "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-1.2.0.tgz"
- },
- "redux-batched-actions": {
- "version": "0.1.5",
- "from": "redux-batched-actions@latest",
- "resolved": "https://registry.npmjs.org/redux-batched-actions/-/redux-batched-actions-0.1.5.tgz"
- },
- "redux-localstorage": {
- "version": "0.4.1",
- "from": "redux-localstorage@0.4.1",
- "resolved": "https://registry.npmjs.org/redux-localstorage/-/redux-localstorage-0.4.1.tgz"
- },
- "redux-raven-middleware": {
- "version": "1.2.0",
- "from": "redux-raven-middleware@latest",
- "resolved": "https://registry.npmjs.org/redux-raven-middleware/-/redux-raven-middleware-1.2.0.tgz"
- },
- "redux-thunk": {
- "version": "2.2.0",
- "from": "redux-thunk@2.2.0",
- "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.2.0.tgz"
- },
- "regenerate": {
- "version": "1.2.1",
- "from": "regenerate@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.2.1.tgz"
- },
- "regenerator-runtime": {
- "version": "0.9.5",
- "from": "regenerator-runtime@>=0.9.5 <0.10.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.5.tgz"
- },
- "regex-cache": {
- "version": "0.4.3",
- "from": "regex-cache@>=0.4.2 <0.5.0",
- "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.3.tgz"
- },
- "regexpu-core": {
- "version": "1.0.0",
- "from": "regexpu-core@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-1.0.0.tgz"
- },
- "regjsgen": {
- "version": "0.2.0",
- "from": "regjsgen@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz"
- },
- "regjsparser": {
- "version": "0.1.5",
- "from": "regjsparser@>=0.1.4 <0.2.0",
- "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz"
- },
- "repeat-element": {
- "version": "1.1.2",
- "from": "repeat-element@>=1.1.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz"
- },
- "repeat-string": {
- "version": "1.5.4",
- "from": "repeat-string@>=1.5.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.5.4.tgz"
- },
- "repeating": {
- "version": "1.1.3",
- "from": "repeating@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz"
- },
- "replace-ext": {
- "version": "0.0.1",
- "from": "replace-ext@0.0.1",
- "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz"
- },
- "require-from-string": {
- "version": "1.2.0",
- "from": "require-from-string@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-1.2.0.tgz"
- },
- "require-nocache": {
- "version": "1.0.0",
- "from": "require-nocache@latest",
- "resolved": "https://registry.npmjs.org/require-nocache/-/require-nocache-1.0.0.tgz"
- },
- "require-uncached": {
- "version": "1.0.3",
- "from": "require-uncached@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz"
- },
- "reselect": {
- "version": "2.5.4",
- "from": "reselect@2.5.4",
- "resolved": "https://registry.npmjs.org/reselect/-/reselect-2.5.4.tgz"
- },
- "resize-observer-polyfill": {
- "version": "1.3.1",
- "from": "resize-observer-polyfill@1.3.1",
- "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.3.1.tgz"
- },
- "resolve": {
- "version": "1.1.7",
- "from": "resolve@>=1.1.5 <2.0.0",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz"
- },
- "resolve-from": {
- "version": "1.0.1",
- "from": "resolve-from@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz"
- },
- "restore-cursor": {
- "version": "1.0.1",
- "from": "restore-cursor@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz"
- },
- "right-align": {
- "version": "0.1.3",
- "from": "right-align@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz"
- },
- "rimraf": {
- "version": "2.5.2",
- "from": "rimraf@>=2.2.8 <3.0.0",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.2.tgz",
- "dependencies": {
- "glob": {
- "version": "7.0.3",
- "from": "glob@>=7.0.0 <8.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.3.tgz"
- }
- }
- },
- "ripemd160": {
- "version": "0.2.0",
- "from": "ripemd160@0.2.0",
- "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-0.2.0.tgz"
- },
- "rocambole": {
- "version": "0.7.0",
- "from": "rocambole@>=0.7.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/rocambole/-/rocambole-0.7.0.tgz"
- },
- "rocambole-indent": {
- "version": "2.0.4",
- "from": "rocambole-indent@>=2.0.4 <3.0.0",
- "resolved": "https://registry.npmjs.org/rocambole-indent/-/rocambole-indent-2.0.4.tgz",
- "dependencies": {
- "mout": {
- "version": "0.11.1",
- "from": "mout@>=0.11.0 <0.12.0",
- "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz"
- }
- }
- },
- "rocambole-linebreak": {
- "version": "1.0.1",
- "from": "rocambole-linebreak@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/rocambole-linebreak/-/rocambole-linebreak-1.0.1.tgz"
- },
- "rocambole-node": {
- "version": "1.0.0",
- "from": "rocambole-node@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/rocambole-node/-/rocambole-node-1.0.0.tgz"
- },
- "rocambole-token": {
- "version": "1.2.1",
- "from": "rocambole-token@>=1.1.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/rocambole-token/-/rocambole-token-1.2.1.tgz"
- },
- "rocambole-whitespace": {
- "version": "1.0.0",
- "from": "rocambole-whitespace@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/rocambole-whitespace/-/rocambole-whitespace-1.0.0.tgz"
- },
- "run-async": {
- "version": "0.1.0",
- "from": "run-async@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz"
- },
- "run-sequence": {
- "version": "1.2.0",
- "from": "run-sequence@1.2.0",
- "resolved": "https://registry.npmjs.org/run-sequence/-/run-sequence-1.2.0.tgz"
- },
- "rx-lite": {
- "version": "3.1.2",
- "from": "rx-lite@>=3.1.2 <4.0.0",
- "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz"
- },
- "sax": {
- "version": "1.2.1",
- "from": "sax@>=1.2.1 <1.3.0",
- "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz"
- },
- "section-iterator": {
- "version": "2.0.0",
- "from": "section-iterator@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz"
- },
- "semver": {
- "version": "4.3.6",
- "from": "semver@>=4.3.1 <5.0.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz"
- },
- "sequencify": {
- "version": "0.0.7",
- "from": "sequencify@>=0.0.7 <0.1.0",
- "resolved": "https://registry.npmjs.org/sequencify/-/sequencify-0.0.7.tgz"
- },
- "serializerr": {
- "version": "1.0.2",
- "from": "serializerr@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/serializerr/-/serializerr-1.0.2.tgz"
- },
- "setimmediate": {
- "version": "1.0.5",
- "from": "setimmediate@>=1.0.5 <2.0.0",
- "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
- },
- "sha.js": {
- "version": "2.2.6",
- "from": "sha.js@2.2.6",
- "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.2.6.tgz"
- },
- "shallow-equal": {
- "version": "1.0.0",
- "from": "shallow-equal@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.0.0.tgz"
- },
- "shallowequal": {
- "version": "0.2.2",
- "from": "shallowequal@>=0.2.2 <0.3.0",
- "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-0.2.2.tgz"
- },
- "shebang-regex": {
- "version": "1.0.0",
- "from": "shebang-regex@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz"
- },
- "shelljs": {
- "version": "0.6.1",
- "from": "shelljs@>=0.6.0 <0.7.0",
- "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.6.1.tgz"
- },
- "sigmund": {
- "version": "1.0.1",
- "from": "sigmund@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
- },
- "signal-exit": {
- "version": "2.1.2",
- "from": "signal-exit@>=2.1.2 <3.0.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-2.1.2.tgz"
- },
- "slash": {
- "version": "1.0.0",
- "from": "slash@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz"
- },
- "slice-ansi": {
- "version": "0.0.4",
- "from": "slice-ansi@0.0.4",
- "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz"
- },
- "sort-keys": {
- "version": "1.1.2",
- "from": "sort-keys@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz"
- },
- "source-list-map": {
- "version": "0.1.6",
- "from": "source-list-map@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-0.1.6.tgz"
- },
- "source-map": {
- "version": "0.5.6",
- "from": "source-map@>=0.5.6 <0.6.0",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz"
- },
- "source-map-support": {
- "version": "0.2.10",
- "from": "source-map-support@>=0.2.10 <0.3.0",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.2.10.tgz",
- "dependencies": {
- "source-map": {
- "version": "0.1.32",
- "from": "source-map@0.1.32",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.32.tgz"
- }
- }
- },
- "sparkles": {
- "version": "1.0.0",
- "from": "sparkles@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.0.tgz"
- },
- "spawn-sync": {
- "version": "1.0.15",
- "from": "spawn-sync@>=1.0.5 <2.0.0",
- "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz"
- },
- "spdx-correct": {
- "version": "1.0.2",
- "from": "spdx-correct@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz"
- },
- "spdx-exceptions": {
- "version": "1.0.4",
- "from": "spdx-exceptions@>=1.0.4 <2.0.0",
- "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-1.0.4.tgz"
- },
- "spdx-expression-parse": {
- "version": "1.0.2",
- "from": "spdx-expression-parse@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.2.tgz"
- },
- "spdx-license-ids": {
- "version": "1.2.1",
- "from": "spdx-license-ids@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.1.tgz"
- },
- "specificity": {
- "version": "0.2.1",
- "from": "specificity@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.2.1.tgz"
- },
- "split": {
- "version": "0.3.3",
- "from": "split@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz"
- },
- "split2": {
- "version": "0.2.1",
- "from": "split2@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/split2/-/split2-0.2.1.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.1 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- }
- }
- },
- "sprintf-js": {
- "version": "1.0.3",
- "from": "sprintf-js@>=1.0.2 <1.1.0",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
- },
- "statuses": {
- "version": "1.3.0",
- "from": "statuses@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz"
- },
- "stdin": {
- "version": "0.0.1",
- "from": "stdin@*",
- "resolved": "https://registry.npmjs.org/stdin/-/stdin-0.0.1.tgz"
- },
- "stream-browserify": {
- "version": "1.0.0",
- "from": "stream-browserify@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-1.0.0.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.1.14",
- "from": "readable-stream@>=1.0.27-1 <2.0.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz"
- }
- }
- },
- "stream-combiner": {
- "version": "0.0.4",
- "from": "stream-combiner@>=0.0.4 <0.1.0",
- "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz"
- },
- "stream-consume": {
- "version": "0.1.0",
- "from": "stream-consume@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz"
- },
- "streamqueue": {
- "version": "1.1.1",
- "from": "streamqueue@1.1.1",
- "resolved": "https://registry.npmjs.org/streamqueue/-/streamqueue-1.1.1.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33 <1.1.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- }
- }
- },
- "strict-uri-encode": {
- "version": "1.1.0",
- "from": "strict-uri-encode@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz"
- },
- "string_decoder": {
- "version": "0.10.31",
- "from": "string_decoder@>=0.10.0 <0.11.0",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
- },
- "string-width": {
- "version": "1.0.1",
- "from": "string-width@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.1.tgz"
- },
- "strip-ansi": {
- "version": "3.0.1",
- "from": "strip-ansi@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz"
- },
- "strip-bom": {
- "version": "2.0.0",
- "from": "strip-bom@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz"
- },
- "strip-bom-stream": {
- "version": "1.0.0",
- "from": "strip-bom-stream@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz"
- },
- "strip-indent": {
- "version": "1.0.1",
- "from": "strip-indent@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz"
- },
- "strip-json-comments": {
- "version": "0.1.3",
- "from": "strip-json-comments@>=0.1.1 <0.2.0",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz"
- },
- "style-loader": {
- "version": "0.13.1",
- "from": "style-loader@0.13.1",
- "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.13.1.tgz"
- },
- "style-search": {
- "version": "0.1.0",
- "from": "style-search@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz"
- },
- "stylehacks": {
- "version": "2.3.1",
- "from": "stylehacks@>=2.3.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-2.3.1.tgz"
- },
- "stylelint": {
- "version": "7.3.1",
- "from": "stylelint@7.3.1",
- "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-7.3.1.tgz",
- "dependencies": {
- "get-stdin": {
- "version": "5.0.1",
- "from": "get-stdin@>=5.0.0 <6.0.0",
- "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz"
- },
- "glob": {
- "version": "7.1.0",
- "from": "glob@>=7.0.3 <8.0.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.0.tgz"
- },
- "globby": {
- "version": "6.0.0",
- "from": "globby@>=6.0.0 <7.0.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-6.0.0.tgz"
- },
- "ignore": {
- "version": "3.1.5",
- "from": "ignore@>=3.1.3 <4.0.0",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.1.5.tgz"
- },
- "minimatch": {
- "version": "3.0.3",
- "from": "minimatch@^3.0.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz"
- },
- "postcss-selector-parser": {
- "version": "2.2.1",
- "from": "postcss-selector-parser@>=2.1.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.2.1.tgz"
- },
- "resolve-from": {
- "version": "2.0.0",
- "from": "resolve-from@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz"
- }
- }
- },
- "sugarss": {
- "version": "0.1.6",
- "from": "sugarss@>=0.1.2 <0.2.0",
- "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-0.1.6.tgz",
- "dependencies": {
- "postcss": {
- "version": "5.2.0",
- "from": "postcss@^5.2.0",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.0.tgz"
- }
- }
- },
- "supports-color": {
- "version": "3.1.2",
- "from": "supports-color@>=3.1.2 <4.0.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz"
- },
- "svg-tags": {
- "version": "1.0.0",
- "from": "svg-tags@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz"
- },
- "svgo": {
- "version": "0.6.6",
- "from": "svgo@>=0.6.1 <0.7.0",
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.6.6.tgz"
- },
- "symbol-observable": {
- "version": "1.0.4",
- "from": "symbol-observable@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.4.tgz"
- },
- "sync-exec": {
- "version": "0.5.0",
- "from": "sync-exec@>=0.5.0 <0.6.0",
- "resolved": "https://registry.npmjs.org/sync-exec/-/sync-exec-0.5.0.tgz"
- },
- "synesthesia": {
- "version": "1.0.1",
- "from": "synesthesia@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/synesthesia/-/synesthesia-1.0.1.tgz"
- },
- "table": {
- "version": "3.7.8",
- "from": "table@>=3.7.8 <4.0.0",
- "resolved": "https://registry.npmjs.org/table/-/table-3.7.8.tgz"
- },
- "tapable": {
- "version": "0.1.10",
- "from": "tapable@>=0.1.8 <0.2.0",
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz"
- },
- "tar": {
- "version": "2.2.1",
- "from": "tar@>=2.1.1 <3.0.0",
- "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz"
- },
- "tar.gz": {
- "version": "1.0.3",
- "from": "tar.gz@1.0.3",
- "resolved": "https://registry.npmjs.org/tar.gz/-/tar.gz-1.0.3.tgz",
- "dependencies": {
- "bluebird": {
- "version": "2.10.2",
- "from": "bluebird@>=2.9.34 <3.0.0",
- "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.10.2.tgz"
- },
- "mout": {
- "version": "0.11.1",
- "from": "mout@>=0.11.0 <0.12.0",
- "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz"
- }
- }
- },
- "tether": {
- "version": "1.4.0",
- "from": "tether@>=1.3.7 <2.0.0",
- "resolved": "https://registry.npmjs.org/tether/-/tether-1.4.0.tgz"
- },
- "text-table": {
- "version": "0.2.0",
- "from": "text-table@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz"
- },
- "through": {
- "version": "2.3.8",
- "from": "through@>=2.3.6 <3.0.0",
- "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz"
- },
- "through2": {
- "version": "2.0.1",
- "from": "through2@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.1.tgz"
- },
- "tildify": {
- "version": "1.2.0",
- "from": "tildify@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz"
- },
- "time-stamp": {
- "version": "1.0.1",
- "from": "time-stamp@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.0.1.tgz"
- },
- "timers-browserify": {
- "version": "1.4.2",
- "from": "timers-browserify@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz"
- },
- "to-fast-properties": {
- "version": "1.0.2",
- "from": "to-fast-properties@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.2.tgz"
- },
- "trim-newlines": {
- "version": "1.0.0",
- "from": "trim-newlines@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz"
- },
- "tryit": {
- "version": "1.0.2",
- "from": "tryit@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/tryit/-/tryit-1.0.2.tgz"
- },
- "tty-browserify": {
- "version": "0.0.0",
- "from": "tty-browserify@0.0.0",
- "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz"
- },
- "tv4": {
- "version": "1.2.7",
- "from": "tv4@>=1.2.7 <2.0.0",
- "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.2.7.tgz"
- },
- "type-check": {
- "version": "0.3.2",
- "from": "type-check@>=0.3.2 <0.4.0",
- "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz"
- },
- "type-detect": {
- "version": "1.0.0",
- "from": "type-detect@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz"
- },
- "type-is": {
- "version": "1.6.13",
- "from": "type-is@>=1.6.10 <1.7.0",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.13.tgz"
- },
- "typedarray": {
- "version": "0.0.6",
- "from": "typedarray@>=0.0.6 <0.0.7",
- "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"
- },
- "ua-parser-js": {
- "version": "0.7.12",
- "from": "ua-parser-js@>=0.7.9 <0.8.0",
- "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.12.tgz"
- },
- "uglify-js": {
- "version": "2.3.6",
- "from": "uglify-js@>=2.3.0 <2.4.0",
- "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.3.6.tgz",
- "dependencies": {
- "optimist": {
- "version": "0.3.7",
- "from": "optimist@>=0.3.5 <0.4.0",
- "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz"
- },
- "source-map": {
- "version": "0.1.43",
- "from": "source-map@>=0.1.7 <0.2.0",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz"
- },
- "wordwrap": {
- "version": "0.0.3",
- "from": "wordwrap@>=0.0.2 <0.1.0",
- "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz"
- }
- }
- },
- "uglify-to-browserify": {
- "version": "1.0.2",
- "from": "uglify-to-browserify@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz"
- },
- "uniq": {
- "version": "1.0.1",
- "from": "uniq@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz"
- },
- "uniqid": {
- "version": "1.0.0",
- "from": "uniqid@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/uniqid/-/uniqid-1.0.0.tgz"
- },
- "uniqs": {
- "version": "2.0.0",
- "from": "uniqs@>=2.0.0 <3.0.0",
- "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz"
- },
- "unique-stream": {
- "version": "1.0.0",
- "from": "unique-stream@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-1.0.0.tgz"
- },
- "unpipe": {
- "version": "1.0.0",
- "from": "unpipe@1.0.0",
- "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
- },
- "url": {
- "version": "0.10.3",
- "from": "url@>=0.10.1 <0.11.0",
- "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz",
- "dependencies": {
- "punycode": {
- "version": "1.3.2",
- "from": "punycode@1.3.2",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz"
- }
- }
- },
- "url-loader": {
- "version": "0.5.7",
- "from": "url-loader@0.5.7",
- "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-0.5.7.tgz",
- "dependencies": {
- "mime": {
- "version": "1.2.11",
- "from": "mime@>=1.2.0 <1.3.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz"
- }
- }
- },
- "user-home": {
- "version": "1.1.1",
- "from": "user-home@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz"
- },
- "util": {
- "version": "0.10.3",
- "from": "util@>=0.10.3 <0.11.0",
- "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz"
- },
- "util-deprecate": {
- "version": "1.0.2",
- "from": "util-deprecate@>=1.0.1 <1.1.0",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
- },
- "v8flags": {
- "version": "2.0.11",
- "from": "v8flags@>=2.0.2 <3.0.0",
- "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.0.11.tgz"
- },
- "validate-npm-package-license": {
- "version": "3.0.1",
- "from": "validate-npm-package-license@>=3.0.1 <4.0.0",
- "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz"
- },
- "vinyl": {
- "version": "0.5.3",
- "from": "vinyl@>=0.5.0 <0.6.0",
- "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.5.3.tgz"
- },
- "vinyl-bufferstream": {
- "version": "1.0.1",
- "from": "vinyl-bufferstream@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz"
- },
- "vinyl-file": {
- "version": "1.3.0",
- "from": "vinyl-file@>=1.2.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-1.3.0.tgz",
- "dependencies": {
- "vinyl": {
- "version": "1.1.1",
- "from": "vinyl@>=1.1.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.1.1.tgz"
- }
- }
- },
- "vinyl-fs": {
- "version": "0.3.14",
- "from": "vinyl-fs@>=0.3.0 <0.4.0",
- "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-0.3.14.tgz",
- "dependencies": {
- "clone": {
- "version": "0.2.0",
- "from": "clone@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz"
- },
- "graceful-fs": {
- "version": "3.0.8",
- "from": "graceful-fs@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.8.tgz"
- },
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.33-1 <1.1.0-0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "strip-bom": {
- "version": "1.0.0",
- "from": "strip-bom@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz"
- },
- "through2": {
- "version": "0.6.5",
- "from": "through2@>=0.6.1 <0.7.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz"
- },
- "vinyl": {
- "version": "0.4.6",
- "from": "vinyl@>=0.4.0 <0.5.0",
- "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.4.6.tgz"
- }
- }
- },
- "vinyl-map": {
- "version": "1.0.1",
- "from": "vinyl-map@>=1.0.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/vinyl-map/-/vinyl-map-1.0.1.tgz",
- "dependencies": {
- "isarray": {
- "version": "0.0.1",
- "from": "isarray@0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
- },
- "readable-stream": {
- "version": "1.0.34",
- "from": "readable-stream@>=1.0.17 <1.1.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz"
- },
- "through2": {
- "version": "0.4.2",
- "from": "through2@>=0.4.1 <0.5.0",
- "resolved": "https://registry.npmjs.org/through2/-/through2-0.4.2.tgz"
- },
- "xtend": {
- "version": "2.1.2",
- "from": "xtend@>=2.1.1 <2.2.0",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz"
- }
- }
- },
- "vinyl-sourcemaps-apply": {
- "version": "0.2.1",
- "from": "vinyl-sourcemaps-apply@>=0.2.0 <0.3.0",
- "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz"
- },
- "vm-browserify": {
- "version": "0.0.4",
- "from": "vm-browserify@0.0.4",
- "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz"
- },
- "warning": {
- "version": "3.0.0",
- "from": "warning@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz"
- },
- "watchpack": {
- "version": "0.2.9",
- "from": "watchpack@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-0.2.9.tgz",
- "dependencies": {
- "async": {
- "version": "0.9.2",
- "from": "async@>=0.9.0 <0.10.0",
- "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz"
- }
- }
- },
- "webpack": {
- "version": "1.13.1",
- "from": "webpack@1.13.1",
- "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.13.1.tgz",
- "dependencies": {
- "async": {
- "version": "1.5.2",
- "from": "async@>=1.3.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz"
- },
- "interpret": {
- "version": "0.6.6",
- "from": "interpret@>=0.6.4 <0.7.0",
- "resolved": "https://registry.npmjs.org/interpret/-/interpret-0.6.6.tgz"
- },
- "uglify-js": {
- "version": "2.6.2",
- "from": "uglify-js@>=2.6.0 <2.7.0",
- "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.6.2.tgz",
- "dependencies": {
- "async": {
- "version": "0.2.10",
- "from": "async@>=0.2.6 <0.3.0",
- "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz"
- }
- }
- }
- }
- },
- "webpack-core": {
- "version": "0.6.8",
- "from": "webpack-core@>=0.6.0 <0.7.0",
- "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.8.tgz",
- "dependencies": {
- "source-map": {
- "version": "0.4.4",
- "from": "source-map@>=0.4.1 <0.5.0",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz"
- }
- }
- },
- "webpack-sources": {
- "version": "0.1.3",
- "from": "webpack-sources@>=0.1.0 <0.2.0",
- "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-0.1.3.tgz"
- },
- "webpack-stream": {
- "version": "2.1.1",
- "from": "webpack-stream@2.1.1",
- "resolved": "https://registry.npmjs.org/webpack-stream/-/webpack-stream-2.1.1.tgz",
- "dependencies": {
- "memory-fs": {
- "version": "0.2.0",
- "from": "memory-fs@>=0.2.0 <0.3.0-0",
- "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz"
- }
- }
- },
- "websocket-driver": {
- "version": "0.6.5",
- "from": "websocket-driver@>=0.3.6",
- "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.6.5.tgz"
- },
- "websocket-extensions": {
- "version": "0.1.1",
- "from": "websocket-extensions@>=0.1.1",
- "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.1.tgz"
- },
- "whatwg-fetch": {
- "version": "2.0.2",
- "from": "whatwg-fetch@>=0.10.0",
- "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz"
- },
- "when": {
- "version": "3.7.7",
- "from": "when@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz"
- },
- "whet.extend": {
- "version": "0.9.9",
- "from": "whet.extend@>=0.9.9 <0.10.0",
- "resolved": "https://registry.npmjs.org/whet.extend/-/whet.extend-0.9.9.tgz"
- },
- "which": {
- "version": "1.2.9",
- "from": "which@>=1.2.4 <2.0.0",
- "resolved": "https://registry.npmjs.org/which/-/which-1.2.9.tgz"
- },
- "window-size": {
- "version": "0.1.0",
- "from": "window-size@0.1.0",
- "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz"
- },
- "wordwrap": {
- "version": "1.0.0",
- "from": "wordwrap@>=1.0.0 <1.1.0",
- "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz"
- },
- "wrappy": {
- "version": "1.0.2",
- "from": "wrappy@>=1.0.0 <2.0.0",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
- },
- "write": {
- "version": "0.2.1",
- "from": "write@>=0.2.1 <0.3.0",
- "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz"
- },
- "write-file-stdout": {
- "version": "0.0.2",
- "from": "write-file-stdout@0.0.2",
- "resolved": "https://registry.npmjs.org/write-file-stdout/-/write-file-stdout-0.0.2.tgz"
- },
- "xregexp": {
- "version": "3.1.1",
- "from": "xregexp@>=3.0.0 <4.0.0",
- "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-3.1.1.tgz"
- },
- "xtend": {
- "version": "4.0.1",
- "from": "xtend@>=4.0.0 <5.0.0",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz"
- },
- "yargs": {
- "version": "3.10.0",
- "from": "yargs@>=3.10.0 <3.11.0",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz",
- "dependencies": {
- "camelcase": {
- "version": "1.2.1",
- "from": "camelcase@>=1.0.2 <2.0.0",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz"
- }
- }
- }
- }
-}
diff --git a/package.json b/package.json
index 0e2bbdd55..85dd8eb59 100644
--- a/package.json
+++ b/package.json
@@ -1,96 +1,114 @@
{
- "name": "Sonarr",
- "version": "2.0.0",
- "description": "Sonarr",
+ "name": "sonarr",
+ "version": "3.0.0",
+ "description": "Sonarr is a PVR for Usenet and BitTorrent users",
"scripts": {
"build": "gulp build",
- "start": "gulp watch"
- },
- "repository": {
- "type": "git",
- "url": "git://github.com/Sonarr/Sonarr.git"
+ "start": "gulp watch",
+ "eslint": "esprint check",
+ "eslint-fix": "eslint start --fix",
+ "stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc"
},
+ "repository": "https://github.com/Sonarr/Sonarr",
"author": "Team Sonarr",
"license": "GPL-3.0",
"readmeFilename": "readme.md",
"dependencies": {
- "autoprefixer": "6.3.6",
- "babel-core": "6.9.0",
- "babel-eslint": "7.1.0",
- "babel-loader": "6.2.4",
- "babel-plugin-transform-class-properties": "6.16.0",
+ "@fortawesome/fontawesome-free": "5.3.1",
+ "@fortawesome/fontawesome-svg-core": "1.2.4",
+ "@fortawesome/free-regular-svg-icons": "5.3.1",
+ "@fortawesome/free-solid-svg-icons": "5.3.1",
+ "@fortawesome/react-fontawesome": "0.1.3",
+ "@sentry/browser": "4.0.3",
+ "autoprefixer": "9.1.5",
+ "babel-core": "6.26.3",
+ "babel-eslint": "9.0.0",
+ "babel-loader": "7.1.2",
+ "babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-decorators-legacy": "1.0.0",
- "babel-preset-es2015": "6.9.0",
- "babel-preset-react": "6.22.0",
- "babel-preset-stage-2": "6.5.0",
- "classnames": "2.2.5",
- "css-loader": "0.23.1",
- "del": "2.2.0",
+ "babel-preset-es2015": "6.24.1",
+ "babel-preset-react": "6.24.1",
+ "babel-preset-stage-2": "6.24.1",
+ "classnames": "2.2.6",
+ "clipboard": "2.0.1",
+ "create-react-class": "15.6.3",
+ "css-loader": "0.28.9",
+ "del": "3.0.0",
"element-class": "0.2.2",
- "esformatter": "0.9.3",
- "eslint": "2.10.2",
- "eslint-loader": "1.3.0",
- "eslint-plugin-filenames": "1.0.0",
- "eslint-plugin-react": "5.2.2",
- "extract-text-webpack-plugin": "1.0.1",
- "file-loader": "0.9.0",
- "filesize": "3.5.4",
+ "esformatter": "0.10.0",
+ "eslint": "5.6.0",
+ "eslint-plugin-filenames": "1.3.2",
+ "eslint-plugin-react": "7.11.1",
+ "esprint": "0.4.0",
+ "extract-text-webpack-plugin": "3.0.2",
+ "file-loader": "1.1.6",
+ "filesize": "3.6.1",
"gulp": "3.9.1",
- "gulp-cached": "1.1.0",
- "gulp-clean-css": "^3.0.4",
- "gulp-concat": "2.6.0",
+ "gulp-cached": "1.1.1",
+ "gulp-clean-css": "3.10.0",
+ "gulp-concat": "2.6.1",
"gulp-declare": "0.3.0",
- "gulp-handlebars": "3.0.1",
- "gulp-less": "3.0.3",
- "gulp-livereload": "3.8.1",
- "gulp-postcss": "6.1.1",
- "gulp-print": "2.0.1",
- "gulp-sourcemaps": "1.6.0",
+ "gulp-livereload": "4.0.0",
+ "gulp-postcss": "8.0.0",
+ "gulp-print": "5.0.0",
+ "gulp-sourcemaps": "2.6.4",
"gulp-stripbom": "1.0.4",
- "gulp-util": "3.0.7",
- "gulp-watch": "4.3.5",
- "gulp-wrap": "0.13.0",
- "handlebars": "3.0.3",
- "lodash": "4.17.4",
- "moment": "2.17.1",
- "normalize.css": "5.0.0",
- "postcss-loader": "0.9.1",
- "postcss-nested": "1.0.0",
- "postcss-simple-vars": "3.0.0",
- "react": "15.4.2",
- "react-addons-shallow-compare": "15.4.2",
- "react-autosuggest": "8.0.0",
- "react-dnd": "2.1.4",
- "react-dnd-html5-backend": "2.1.2",
- "react-document-title": "2.0.2",
- "react-dom": "15.4.2",
- "react-google-recaptcha": "0.5.4",
- "react-lazyload": "2.2.0",
- "react-measure": "1.4.5",
- "react-portal": "3.0.0",
- "react-redux": "5.0.2",
- "react-router": "3.0.2",
- "react-router-redux": "4.0.7",
- "react-slider": "0.7.0",
- "react-tabs": "0.8.2",
- "react-tag-autocomplete": "5.1.0",
- "react-tether": "0.5.5",
- "react-virtualized": "8.11.3",
- "redux": "3.6.0",
- "redux-actions": "1.2.0",
- "redux-batched-actions": "0.1.5",
+ "gulp-util": "3.0.8",
+ "gulp-watch": "5.0.1",
+ "gulp-wrap": "0.14.0",
+ "history": "4.7.2",
+ "jdu": "1.0.0",
+ "jquery": "3.3.1",
+ "loader-utils": "^1.1.0",
+ "lodash": "4.17.11",
+ "mobile-detect": "1.4.3",
+ "moment": "2.22.2",
+ "mousetrap": "1.6.2",
+ "normalize.css": "8.0.0",
+ "postcss-loader": "3.0.0",
+ "postcss-mixins": "6.2.0",
+ "postcss-nested": "4.1.0",
+ "postcss-simple-vars": "5.0.1",
+ "prop-types": "15.6.2",
+ "qs": "6.5.2",
+ "react": "16.5.2",
+ "react-addons-shallow-compare": "15.6.2",
+ "react-async-script": "1.0.0",
+ "react-autosuggest": "9.4.1",
+ "react-custom-scrollbars": "4.2.1",
+ "react-dnd": "5.0.0",
+ "react-dnd-html5-backend": "5.0.1",
+ "react-document-title": "2.0.3",
+ "react-dom": "16.5.2",
+ "react-google-recaptcha": "1.0.2",
+ "react-lazyload": "2.3.0",
+ "react-measure": "1.4.7",
+ "react-redux": "5.0.7",
+ "react-router-dom": "4.3.1",
+ "react-router-redux": "5.0.0-alpha.6",
+ "react-slider": "0.11.2",
+ "react-tabs": "2.3.0",
+ "react-tether": "1.0.1",
+ "react-text-truncate": "0.13.1",
+ "react-virtualized": "9.20.1",
+ "redux": "4.0.0",
+ "redux-actions": "2.6.1",
+ "redux-batched-actions": "0.4.0",
"redux-localstorage": "0.4.1",
- "redux-raven-middleware": "1.2.0",
- "redux-thunk": "2.2.0",
+ "redux-thunk": "2.3.0",
"require-nocache": "1.0.0",
- "reselect": "2.5.4",
- "run-sequence": "1.2.0",
- "streamqueue": "1.1.1",
- "style-loader": "0.13.1",
- "stylelint": "7.3.1",
- "tar.gz": "1.0.3",
- "url-loader": "0.5.7",
- "webpack": "1.13.1",
- "webpack-stream": "2.1.1"
- }
+ "reselect": "3.0.1",
+ "run-sequence": "2.2.1",
+ "signalr": "2.4.0",
+ "streamqueue": "1.1.2",
+ "style-loader": "0.19.1",
+ "stylelint": "9.5.0",
+ "stylelint-order": "1.0.0",
+ "tar.gz": "1.0.7",
+ "uglifyjs-webpack-plugin": "1.2.5",
+ "url-loader": "0.6.2",
+ "webpack": "3.10.0",
+ "webpack-stream": "^4.0.0"
+ },
+ "main": "index.js"
}
diff --git a/src/NzbDrone.Common/Serializer/Json.cs b/src/NzbDrone.Common/Serializer/Json.cs
index 4a0565a4d..90023e8f3 100644
--- a/src/NzbDrone.Common/Serializer/Json.cs
+++ b/src/NzbDrone.Common/Serializer/Json.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using System.Reflection;
using Newtonsoft.Json;
@@ -40,7 +40,7 @@ namespace NzbDrone.Common.Serializer
{
try
{
- return JsonConvert.DeserializeObject(json, SerializerSetting);
+ return JsonConvert.DeserializeObject(json, SerializerSettings);
}
catch (JsonReaderException ex)
{
@@ -52,7 +52,7 @@ namespace NzbDrone.Common.Serializer
{
try
{
- return JsonConvert.DeserializeObject(json, type, SerializerSetting);
+ return JsonConvert.DeserializeObject(json, type, SerializerSettings);
}
catch (JsonReaderException ex)
{
diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs
index c64adfe80..eed192a6a 100644
--- a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs
+++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
diff --git a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs
index 1dbc4dfa4..c32fcc796 100644
--- a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs
+++ b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using Microsoft.AspNet.SignalR;
using NzbDrone.Common.Composition;
using NzbDrone.SignalR;
@@ -22,7 +22,7 @@ namespace NzbDrone.Host.Owin.MiddleWare
public void Attach(IAppBuilder appBuilder)
{
- appBuilder.MapConnection("/signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration());
+ appBuilder.MapSignalR("/signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration());
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs
index c0676cd24..792c4d12c 100644
--- a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs
+++ b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
@@ -70,7 +70,7 @@ namespace NzbDrone.Host.Owin
private void BuildApp(IAppBuilder appBuilder)
{
- appBuilder.Properties["host.AppName"] = "NzbDrone";
+ appBuilder.Properties["host.AppName"] = "Sonarr";
foreach (var middleWare in _owinMiddleWares.OrderBy(c => c.Order))
{
@@ -88,4 +88,4 @@ namespace NzbDrone.Host.Owin
return provider;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Sonarr.sln b/src/Sonarr.sln
index fc53dfb4d..0d25c827a 100644
--- a/src/Sonarr.sln
+++ b/src/Sonarr.sln
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
-VisualStudioVersion = 15.0.26730.10
+VisualStudioVersion = 15.0.27130.2010
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Console", "NzbDrone.Console\NzbDrone.Console.csproj", "{3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}"
ProjectSection(ProjectDependencies) = postProject
@@ -106,6 +106,12 @@ Global
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86
+ {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86
+ {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86
+ {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86
+ {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86
+ {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86
{FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.ActiveCfg = Debug|x86
{FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.Build.0 = Debug|x86
{FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.ActiveCfg = Release|x86
@@ -200,12 +206,6 @@ Global
{FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Mono|x86.Build.0 = Release|x86
{FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.ActiveCfg = Release|x86
{FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.Build.0 = Release|x86
- {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86
- {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86
- {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86
- {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86
- {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86
- {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86
{95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.ActiveCfg = Debug|x86
{95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.Build.0 = Debug|x86
{95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.ActiveCfg = Debug|x86
@@ -290,6 +290,7 @@ Global
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
+ {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9}
{47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} = {57A04B72-8088-4F75-A582-1158CF8291F7}
{FAFB5948-A222-4CF6-AD14-026BE7564802} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97}
{CADDFCE0-7509-4430-8364-2074E1EEFCA2} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97}
@@ -303,7 +304,6 @@ Global
{CC26800D-F67E-464B-88DE-8EB1A0C227A3} = {57A04B72-8088-4F75-A582-1158CF8291F7}
{6BCE712F-846D-4846-9D1B-A66B858DA755} = {F9E67978-5CD6-4A5F-827B-4249711C0B02}
{700D0B95-95CD-43F3-B6C9-FAA0FC1358D4} = {F9E67978-5CD6-4A5F-827B-4249711C0B02}
- {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9}
{95C11A9E-56ED-456A-8447-2C89C1139266} = {486ADF86-DD89-4E19-B805-9D94F19800D9}
{D12F7F2F-8A3C-415F-88FA-6DD061A84869} = {486ADF86-DD89-4E19-B805-9D94F19800D9}
{F6FC6BE7-0847-4817-A1ED-223DC647C3D7} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
@@ -318,8 +318,8 @@ Global
{74420A79-CC16-442C-8B1E-7C1B913844F0} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {2955716E-0882-41EC-935D-C95694C5C30F}
EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.0\lib\NET35;packages\Unity.2.1.505.2\lib\NET35
+ SolutionGuid = {2955716E-0882-41EC-935D-C95694C5C30F}
EndGlobalSection
GlobalSection(MonoDevelopProperties) = preSolution
StartupItem = NzbDrone.Console\NzbDrone.Console.csproj
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 000000000..22d1eeec9
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,8528 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
+ dependencies:
+ "@babel/highlight" "^7.0.0"
+
+"@babel/core@^7.0.0-rc.1":
+ version "7.0.1"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.0.1.tgz#406658caed0e9686fa4feb5c2f3cefb6161c0f41"
+ dependencies:
+ "@babel/code-frame" "^7.0.0"
+ "@babel/generator" "^7.0.0"
+ "@babel/helpers" "^7.0.0"
+ "@babel/parser" "^7.0.0"
+ "@babel/template" "^7.0.0"
+ "@babel/traverse" "^7.0.0"
+ "@babel/types" "^7.0.0"
+ convert-source-map "^1.1.0"
+ debug "^3.1.0"
+ json5 "^0.5.0"
+ lodash "^4.17.10"
+ resolve "^1.3.2"
+ semver "^5.4.1"
+ source-map "^0.5.0"
+
+"@babel/generator@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.0.0.tgz#1efd58bffa951dc846449e58ce3a1d7f02d393aa"
+ dependencies:
+ "@babel/types" "^7.0.0"
+ jsesc "^2.5.1"
+ lodash "^4.17.10"
+ source-map "^0.5.0"
+ trim-right "^1.0.1"
+
+"@babel/helper-function-name@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0.tgz#a68cc8d04420ccc663dd258f9cc41b8261efa2d4"
+ dependencies:
+ "@babel/helper-get-function-arity" "^7.0.0"
+ "@babel/template" "^7.0.0"
+ "@babel/types" "^7.0.0"
+
+"@babel/helper-get-function-arity@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3"
+ dependencies:
+ "@babel/types" "^7.0.0"
+
+"@babel/helper-split-export-declaration@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0.tgz#3aae285c0311c2ab095d997b8c9a94cad547d813"
+ dependencies:
+ "@babel/types" "^7.0.0"
+
+"@babel/helpers@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.0.0.tgz#7213388341eeb07417f44710fd7e1d00acfa6ac0"
+ dependencies:
+ "@babel/template" "^7.0.0"
+ "@babel/traverse" "^7.0.0"
+ "@babel/types" "^7.0.0"
+
+"@babel/highlight@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4"
+ dependencies:
+ chalk "^2.0.0"
+ esutils "^2.0.2"
+ js-tokens "^4.0.0"
+
+"@babel/parser@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.0.0.tgz#697655183394facffb063437ddf52c0277698775"
+
+"@babel/template@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0.tgz#c2bc9870405959c89a9c814376a2ecb247838c80"
+ dependencies:
+ "@babel/code-frame" "^7.0.0"
+ "@babel/parser" "^7.0.0"
+ "@babel/types" "^7.0.0"
+
+"@babel/traverse@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.0.0.tgz#b1fe9b6567fdf3ab542cfad6f3b31f854d799a61"
+ dependencies:
+ "@babel/code-frame" "^7.0.0"
+ "@babel/generator" "^7.0.0"
+ "@babel/helper-function-name" "^7.0.0"
+ "@babel/helper-split-export-declaration" "^7.0.0"
+ "@babel/parser" "^7.0.0"
+ "@babel/types" "^7.0.0"
+ debug "^3.1.0"
+ globals "^11.1.0"
+ lodash "^4.17.10"
+
+"@babel/types@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0.tgz#6e191793d3c854d19c6749989e3bc55f0e962118"
+ dependencies:
+ esutils "^2.0.2"
+ lodash "^4.17.10"
+ to-fast-properties "^2.0.0"
+
+"@fortawesome/fontawesome-common-types@^0.2.4":
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.4.tgz#7c560ff732c6c7c7c179ae25227ce5449e6f6d65"
+
+"@fortawesome/fontawesome-free@5.3.1":
+ version "5.3.1"
+ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.3.1.tgz#5466b8f31c1f493a96754c1426c25796d0633dd9"
+
+"@fortawesome/fontawesome-svg-core@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.4.tgz#2e40c65e66c7ad5aabc179a2d7c5827b1599905c"
+ dependencies:
+ "@fortawesome/fontawesome-common-types" "^0.2.4"
+
+"@fortawesome/free-regular-svg-icons@5.3.1":
+ version "5.3.1"
+ resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.3.1.tgz#edd019dc61a991c7e3cb9d862a6efd971675e3ba"
+ dependencies:
+ "@fortawesome/fontawesome-common-types" "^0.2.4"
+
+"@fortawesome/free-solid-svg-icons@5.3.1":
+ version "5.3.1"
+ resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.3.1.tgz#9660bece3c4850d58f1653e26c1693487ff74b08"
+ dependencies:
+ "@fortawesome/fontawesome-common-types" "^0.2.4"
+
+"@fortawesome/react-fontawesome@0.1.3":
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.3.tgz#266b4047892c3d10498af1075d89252f74015b11"
+ dependencies:
+ humps "^2.0.1"
+ prop-types "^15.5.10"
+
+"@gulp-sourcemaps/identity-map@1.X":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.1.tgz#cfa23bc5840f9104ce32a65e74db7e7a974bbee1"
+ dependencies:
+ acorn "^5.0.3"
+ css "^2.2.1"
+ normalize-path "^2.1.1"
+ source-map "^0.5.6"
+ through2 "^2.0.3"
+
+"@gulp-sourcemaps/map-sources@1.X":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz#890ae7c5d8c877f6d384860215ace9d7ec945bda"
+ dependencies:
+ normalize-path "^2.0.1"
+ through2 "^2.0.3"
+
+"@mrmlnc/readdir-enhanced@^2.2.1":
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
+ dependencies:
+ call-me-maybe "^1.0.1"
+ glob-to-regexp "^0.3.0"
+
+"@nodelib/fs.stat@^1.0.1":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26"
+
+"@sentry/browser@4.0.3":
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-4.0.3.tgz#a748ce8b7695246d8d36f6c1fe209d259e18f1c2"
+ dependencies:
+ "@sentry/core" "4.0.3"
+ "@sentry/types" "4.0.1"
+ "@sentry/utils" "4.0.1"
+ md5 "2.2.1"
+
+"@sentry/core@4.0.3":
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.0.3.tgz#4f8fd67888f1cf0f1a984c5fa362122b60e8bd08"
+ dependencies:
+ "@sentry/hub" "4.0.1"
+ "@sentry/minimal" "4.0.1"
+ "@sentry/types" "4.0.1"
+ "@sentry/utils" "4.0.1"
+
+"@sentry/hub@4.0.1":
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.0.1.tgz#01870cede195029ae32d763199ff6c3e4edf99d1"
+ dependencies:
+ "@sentry/types" "4.0.0"
+ "@sentry/utils" "4.0.1"
+
+"@sentry/minimal@4.0.1":
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.0.1.tgz#c51a2af81eba48977fb54ab187e0c0eb0ad12c15"
+ dependencies:
+ "@sentry/hub" "4.0.1"
+ "@sentry/types" "4.0.0"
+
+"@sentry/types@4.0.0":
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.0.tgz#9dd46a7b05004871fe0cea0b0423098d9d91a173"
+
+"@sentry/types@4.0.1":
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.1.tgz#f9342e905ce2aee71975574589d915b6fb691fb0"
+
+"@sentry/utils@4.0.1":
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.0.1.tgz#5690058fb030c23d46ea056aa3e8ebebb8105d45"
+ dependencies:
+ "@sentry/types" "4.0.0"
+
+abbrev@1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f"
+
+acorn-dynamic-import@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4"
+ dependencies:
+ acorn "^4.0.3"
+
+acorn-jsx@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-4.1.1.tgz#e8e41e48ea2fe0c896740610ab6a4ffd8add225e"
+ dependencies:
+ acorn "^5.0.3"
+
+acorn-to-esprima@^2.0.6, acorn-to-esprima@^2.0.8:
+ version "2.0.8"
+ resolved "https://registry.yarnpkg.com/acorn-to-esprima/-/acorn-to-esprima-2.0.8.tgz#003f0c642eb92132f417d3708f14ada82adf2eb1"
+
+acorn@5.X:
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822"
+
+acorn@^4.0.3:
+ version "4.0.13"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787"
+
+acorn@^5.0.0, acorn@^5.0.3:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.2.tgz#911cb53e036807cf0fa778dc5d370fbd864246d7"
+
+acorn@^5.6.0:
+ version "5.7.3"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
+
+add-px-to-style@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a"
+
+ajv-errors@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.0.tgz#ecf021fa108fd17dfb5e6b383f2dd233e31ffc59"
+
+ajv-keywords@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0"
+
+ajv-keywords@^3.0.0, ajv-keywords@^3.1.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a"
+
+ajv@^5.0.0, ajv@^5.1.5:
+ version "5.2.3"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.3.tgz#c06f598778c44c6b161abafe3466b81ad1814ed2"
+ dependencies:
+ co "^4.6.0"
+ fast-deep-equal "^1.0.0"
+ json-schema-traverse "^0.3.0"
+ json-stable-stringify "^1.0.1"
+
+ajv@^5.1.0:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39"
+ dependencies:
+ co "^4.6.0"
+ fast-deep-equal "^1.0.0"
+ json-schema-traverse "^0.3.0"
+ json-stable-stringify "^1.0.1"
+
+ajv@^6.0.1, ajv@^6.5.3:
+ version "6.5.3"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.3.tgz#71a569d189ecf4f4f321224fecb166f071dd90f9"
+ dependencies:
+ fast-deep-equal "^2.0.1"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.4.1"
+ uri-js "^4.2.2"
+
+ajv@^6.1.0:
+ version "6.5.0"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.0.tgz#4c8affdf80887d8f132c9c52ab8a2dc4d0b7b24c"
+ dependencies:
+ fast-deep-equal "^2.0.1"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.3.0"
+ uri-js "^4.2.1"
+
+align-text@^0.1.1, align-text@^0.1.3:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117"
+ dependencies:
+ kind-of "^3.0.2"
+ longest "^1.0.1"
+ repeat-string "^1.5.2"
+
+alphanum-sort@^1.0.1, alphanum-sort@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
+
+amdefine@>=0.0.4:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
+
+ansi-colors@1.1.0, ansi-colors@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9"
+ dependencies:
+ ansi-wrap "^0.1.0"
+
+ansi-cyan@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/ansi-cyan/-/ansi-cyan-0.1.1.tgz#538ae528af8982f28ae30d86f2f17456d2609873"
+ dependencies:
+ ansi-wrap "0.1.0"
+
+ansi-escapes@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92"
+
+ansi-gray@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/ansi-gray/-/ansi-gray-0.1.1.tgz#2962cf54ec9792c48510a3deb524436861ef7251"
+ dependencies:
+ ansi-wrap "0.1.0"
+
+ansi-red@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/ansi-red/-/ansi-red-0.1.1.tgz#8c638f9d1080800a353c9c28c8a81ca4705d946c"
+ dependencies:
+ ansi-wrap "0.1.0"
+
+ansi-regex@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+
+ansi-regex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+
+ansi-styles@^2.0.1, ansi-styles@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
+
+ansi-styles@^3.2.1:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+ dependencies:
+ color-convert "^1.9.0"
+
+ansi-wrap@0.1.0, ansi-wrap@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf"
+
+anymatch@^1.3.0:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
+ dependencies:
+ micromatch "^2.1.5"
+ normalize-path "^2.0.0"
+
+anymatch@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
+ dependencies:
+ micromatch "^3.1.4"
+ normalize-path "^2.1.1"
+
+aproba@^1.0.3, aproba@^1.1.1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+
+archy@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40"
+
+are-we-there-yet@~1.1.2:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz#bb5dca382bb94f05e15194373d16fd3ba1ca110d"
+ dependencies:
+ delegates "^1.0.0"
+ readable-stream "^2.0.6"
+
+argparse@^1.0.7:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
+ dependencies:
+ sprintf-js "~1.0.2"
+
+arr-diff@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-1.1.0.tgz#687c32758163588fef7de7b36fabe495eb1a399a"
+ dependencies:
+ arr-flatten "^1.0.1"
+ array-slice "^0.2.3"
+
+arr-diff@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
+ dependencies:
+ arr-flatten "^1.0.1"
+
+arr-diff@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
+
+arr-flatten@^1.0.1, arr-flatten@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+
+arr-union@^2.0.1:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-2.1.0.tgz#20f9eab5ec70f5c7d215b1077b1c39161d292c7d"
+
+arr-union@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
+
+array-differ@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
+
+array-each@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f"
+
+array-find-index@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
+
+array-includes@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d"
+ dependencies:
+ define-properties "^1.1.2"
+ es-abstract "^1.7.0"
+
+array-slice@^0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5"
+
+array-slice@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-1.0.0.tgz#e73034f00dcc1f40876008fd20feae77bd4b7c2f"
+
+array-union@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
+ dependencies:
+ array-uniq "^1.0.1"
+
+array-uniq@^1.0.1, array-uniq@^1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
+
+array-unique@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
+
+array-unique@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
+
+arrify@^1.0.0, arrify@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
+
+asap@^2.0.6, asap@~2.0.3:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
+
+asn1.js@^4.0.0:
+ version "4.9.1"
+ resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40"
+ dependencies:
+ bn.js "^4.0.0"
+ inherits "^2.0.1"
+ minimalistic-assert "^1.0.0"
+
+asn1@~0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86"
+
+assert-plus@1.0.0, assert-plus@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
+
+assert@^1.1.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91"
+ dependencies:
+ util "0.10.3"
+
+assign-symbols@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
+
+async-each@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
+
+async@^2.1.2, async@^2.4.1:
+ version "2.5.0"
+ resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d"
+ dependencies:
+ lodash "^4.14.0"
+
+asynckit@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+
+atob@^2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
+
+atob@~1.1.0:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773"
+
+autobind-decorator@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.1.0.tgz#4451240dbfeff46361c506575a63ed40f0e5bc68"
+
+autoprefixer@9.1.5, autoprefixer@^9.0.0:
+ version "9.1.5"
+ resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.1.5.tgz#8675fd8d1c0d43069f3b19a2c316f3524e4f6671"
+ dependencies:
+ browserslist "^4.1.0"
+ caniuse-lite "^1.0.30000884"
+ normalize-range "^0.1.2"
+ num2fraction "^1.2.2"
+ postcss "^7.0.2"
+ postcss-value-parser "^3.2.3"
+
+autoprefixer@^6.3.1:
+ version "6.3.6"
+ resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.3.6.tgz#de772e1fcda08dce0e992cecf79252d5f008e367"
+ dependencies:
+ browserslist "~1.3.1"
+ caniuse-db "^1.0.30000444"
+ normalize-range "^0.1.2"
+ num2fraction "^1.2.2"
+ postcss "^5.0.19"
+ postcss-value-parser "^3.2.3"
+
+aws-sign2@~0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+
+aws4@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
+
+babel-code-frame@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
+ dependencies:
+ chalk "^1.1.3"
+ esutils "^2.0.2"
+ js-tokens "^3.0.2"
+
+babel-core@6.26.3:
+ version "6.26.3"
+ resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ babel-generator "^6.26.0"
+ babel-helpers "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-register "^6.26.0"
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ convert-source-map "^1.5.1"
+ debug "^2.6.9"
+ json5 "^0.5.1"
+ lodash "^4.17.4"
+ minimatch "^3.0.4"
+ path-is-absolute "^1.0.1"
+ private "^0.1.8"
+ slash "^1.0.0"
+ source-map "^0.5.7"
+
+babel-core@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.0.tgz#af32f78b31a6fcef119c87b0fd8d9753f03a0bb8"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ babel-generator "^6.26.0"
+ babel-helpers "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-register "^6.26.0"
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ convert-source-map "^1.5.0"
+ debug "^2.6.8"
+ json5 "^0.5.1"
+ lodash "^4.17.4"
+ minimatch "^3.0.4"
+ path-is-absolute "^1.0.1"
+ private "^0.1.7"
+ slash "^1.0.0"
+ source-map "^0.5.6"
+
+babel-eslint@9.0.0:
+ version "9.0.0"
+ resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-9.0.0.tgz#7d9445f81ed9f60aff38115f838970df9f2b6220"
+ dependencies:
+ "@babel/code-frame" "^7.0.0"
+ "@babel/parser" "^7.0.0"
+ "@babel/traverse" "^7.0.0"
+ "@babel/types" "^7.0.0"
+ eslint-scope "3.7.1"
+ eslint-visitor-keys "^1.0.0"
+
+babel-generator@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.0.tgz#ac1ae20070b79f6e3ca1d3269613053774f20dc5"
+ dependencies:
+ babel-messages "^6.23.0"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ detect-indent "^4.0.0"
+ jsesc "^1.3.0"
+ lodash "^4.17.4"
+ source-map "^0.5.6"
+ trim-right "^1.0.1"
+
+babel-helper-bindify-decorators@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.24.1.tgz#14c19e5f142d7b47f19a52431e52b1ccbc40a330"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-builder-binary-assignment-operator-visitor@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664"
+ dependencies:
+ babel-helper-explode-assignable-expression "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-builder-react-jsx@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz#39ff8313b75c8b65dceff1f31d383e0ff2a408a0"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ esutils "^2.0.2"
+
+babel-helper-call-delegate@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz#ece6aacddc76e41c3461f88bfc575bd0daa2df8d"
+ dependencies:
+ babel-helper-hoist-variables "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-define-map@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz#a5f56dab41a25f97ecb498c7ebaca9819f95be5f"
+ dependencies:
+ babel-helper-function-name "^6.24.1"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ lodash "^4.17.4"
+
+babel-helper-explode-assignable-expression@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz#f25b82cf7dc10433c55f70592d5746400ac22caa"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-explode-class@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-explode-class/-/babel-helper-explode-class-6.24.1.tgz#7dc2a3910dee007056e1e31d640ced3d54eaa9eb"
+ dependencies:
+ babel-helper-bindify-decorators "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-function-name@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9"
+ dependencies:
+ babel-helper-get-function-arity "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-get-function-arity@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz#8f7782aa93407c41d3aa50908f89b031b1b6853d"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-hoist-variables@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz#1ecb27689c9d25513eadbc9914a73f5408be7a76"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-optimise-call-expression@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz#f7a13427ba9f73f8f4fa993c54a97882d1244257"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-regex@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz#325c59f902f82f24b74faceed0363954f6495e72"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ lodash "^4.17.4"
+
+babel-helper-remap-async-to-generator@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz#5ec581827ad723fecdd381f1c928390676e4551b"
+ dependencies:
+ babel-helper-function-name "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-replace-supers@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz#bf6dbfe43938d17369a213ca8a8bf74b6a90ab1a"
+ dependencies:
+ babel-helper-optimise-call-expression "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helpers@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-loader@7.1.2:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.2.tgz#f6cbe122710f1aa2af4d881c6d5b54358ca24126"
+ dependencies:
+ find-cache-dir "^1.0.0"
+ loader-utils "^1.0.2"
+ mkdirp "^0.5.1"
+
+babel-messages@^6.23.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-check-es2015-constants@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz#35157b101426fd2ffd3da3f75c7d1e91835bbf8a"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-syntax-async-functions@^6.8.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95"
+
+babel-plugin-syntax-async-generators@^6.5.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-generators/-/babel-plugin-syntax-async-generators-6.13.0.tgz#6bc963ebb16eccbae6b92b596eb7f35c342a8b9a"
+
+babel-plugin-syntax-class-properties@^6.8.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz#d7eb23b79a317f8543962c505b827c7d6cac27de"
+
+babel-plugin-syntax-decorators@^6.1.18, babel-plugin-syntax-decorators@^6.13.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz#312563b4dbde3cc806cee3e416cceeaddd11ac0b"
+
+babel-plugin-syntax-dynamic-import@^6.18.0:
+ version "6.18.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz#8d6a26229c83745a9982a441051572caa179b1da"
+
+babel-plugin-syntax-exponentiation-operator@^6.8.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de"
+
+babel-plugin-syntax-flow@^6.18.0:
+ version "6.18.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz#4c3ab20a2af26aa20cd25995c398c4eb70310c8d"
+
+babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0:
+ version "6.18.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
+
+babel-plugin-syntax-object-rest-spread@^6.8.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5"
+
+babel-plugin-syntax-trailing-function-commas@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3"
+
+babel-plugin-transform-async-generator-functions@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-generator-functions/-/babel-plugin-transform-async-generator-functions-6.24.1.tgz#f058900145fd3e9907a6ddf28da59f215258a5db"
+ dependencies:
+ babel-helper-remap-async-to-generator "^6.24.1"
+ babel-plugin-syntax-async-generators "^6.5.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-async-to-generator@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761"
+ dependencies:
+ babel-helper-remap-async-to-generator "^6.24.1"
+ babel-plugin-syntax-async-functions "^6.8.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-class-properties@6.24.1, babel-plugin-transform-class-properties@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.24.1.tgz#6a79763ea61d33d36f37b611aa9def81a81b46ac"
+ dependencies:
+ babel-helper-function-name "^6.24.1"
+ babel-plugin-syntax-class-properties "^6.8.0"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-decorators-legacy@^1.3.4:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.4.tgz#741b58f6c5bce9e6027e0882d9c994f04f366925"
+ dependencies:
+ babel-plugin-syntax-decorators "^6.1.18"
+ babel-runtime "^6.2.0"
+ babel-template "^6.3.0"
+
+babel-plugin-transform-decorators@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators/-/babel-plugin-transform-decorators-6.24.1.tgz#788013d8f8c6b5222bdf7b344390dfd77569e24d"
+ dependencies:
+ babel-helper-explode-class "^6.24.1"
+ babel-plugin-syntax-decorators "^6.13.0"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-arrow-functions@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-block-scoped-functions@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz#bbc51b49f964d70cb8d8e0b94e820246ce3a6141"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-block-scoping@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz#d70f5299c1308d05c12f463813b0a09e73b1895f"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ lodash "^4.17.4"
+
+babel-plugin-transform-es2015-classes@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db"
+ dependencies:
+ babel-helper-define-map "^6.24.1"
+ babel-helper-function-name "^6.24.1"
+ babel-helper-optimise-call-expression "^6.24.1"
+ babel-helper-replace-supers "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-computed-properties@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-destructuring@^6.22.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-duplicate-keys@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-for-of@^6.22.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-function-name@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b"
+ dependencies:
+ babel-helper-function-name "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-literals@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz#4f54a02d6cd66cf915280019a31d31925377ca2e"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-modules-amd@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154"
+ dependencies:
+ babel-plugin-transform-es2015-modules-commonjs "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-modules-commonjs@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz#0d8394029b7dc6abe1a97ef181e00758dd2e5d8a"
+ dependencies:
+ babel-plugin-transform-strict-mode "^6.24.1"
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-types "^6.26.0"
+
+babel-plugin-transform-es2015-modules-systemjs@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23"
+ dependencies:
+ babel-helper-hoist-variables "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-modules-umd@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468"
+ dependencies:
+ babel-plugin-transform-es2015-modules-amd "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-object-super@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d"
+ dependencies:
+ babel-helper-replace-supers "^6.24.1"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-parameters@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b"
+ dependencies:
+ babel-helper-call-delegate "^6.24.1"
+ babel-helper-get-function-arity "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-shorthand-properties@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-spread@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz#d6d68a99f89aedc4536c81a542e8dd9f1746f8d1"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-sticky-regex@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc"
+ dependencies:
+ babel-helper-regex "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-template-literals@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz#a84b3450f7e9f8f1f6839d6d687da84bb1236d8d"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-typeof-symbol@^6.22.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-unicode-regex@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9"
+ dependencies:
+ babel-helper-regex "^6.24.1"
+ babel-runtime "^6.22.0"
+ regexpu-core "^2.0.0"
+
+babel-plugin-transform-exponentiation-operator@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e"
+ dependencies:
+ babel-helper-builder-binary-assignment-operator-visitor "^6.24.1"
+ babel-plugin-syntax-exponentiation-operator "^6.8.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-flow-strip-types@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz#84cb672935d43714fdc32bce84568d87441cf7cf"
+ dependencies:
+ babel-plugin-syntax-flow "^6.18.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-object-rest-spread@^6.22.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06"
+ dependencies:
+ babel-plugin-syntax-object-rest-spread "^6.8.0"
+ babel-runtime "^6.26.0"
+
+babel-plugin-transform-react-display-name@^6.23.0:
+ version "6.25.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz#67e2bf1f1e9c93ab08db96792e05392bf2cc28d1"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-react-jsx-self@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz#df6d80a9da2612a121e6ddd7558bcbecf06e636e"
+ dependencies:
+ babel-plugin-syntax-jsx "^6.8.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-react-jsx-source@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz#66ac12153f5cd2d17b3c19268f4bf0197f44ecd6"
+ dependencies:
+ babel-plugin-syntax-jsx "^6.8.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-react-jsx@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz#840a028e7df460dfc3a2d29f0c0d91f6376e66a3"
+ dependencies:
+ babel-helper-builder-react-jsx "^6.24.1"
+ babel-plugin-syntax-jsx "^6.8.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-regenerator@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f"
+ dependencies:
+ regenerator-transform "^0.10.0"
+
+babel-plugin-transform-strict-mode@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-preset-decorators-legacy@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/babel-preset-decorators-legacy/-/babel-preset-decorators-legacy-1.0.0.tgz#87772ec5303c5a3b748ce450c8400975662d1731"
+ dependencies:
+ babel-plugin-transform-decorators-legacy "^1.3.4"
+
+babel-preset-es2015@6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz#d44050d6bc2c9feea702aaf38d727a0210538939"
+ dependencies:
+ babel-plugin-check-es2015-constants "^6.22.0"
+ babel-plugin-transform-es2015-arrow-functions "^6.22.0"
+ babel-plugin-transform-es2015-block-scoped-functions "^6.22.0"
+ babel-plugin-transform-es2015-block-scoping "^6.24.1"
+ babel-plugin-transform-es2015-classes "^6.24.1"
+ babel-plugin-transform-es2015-computed-properties "^6.24.1"
+ babel-plugin-transform-es2015-destructuring "^6.22.0"
+ babel-plugin-transform-es2015-duplicate-keys "^6.24.1"
+ babel-plugin-transform-es2015-for-of "^6.22.0"
+ babel-plugin-transform-es2015-function-name "^6.24.1"
+ babel-plugin-transform-es2015-literals "^6.22.0"
+ babel-plugin-transform-es2015-modules-amd "^6.24.1"
+ babel-plugin-transform-es2015-modules-commonjs "^6.24.1"
+ babel-plugin-transform-es2015-modules-systemjs "^6.24.1"
+ babel-plugin-transform-es2015-modules-umd "^6.24.1"
+ babel-plugin-transform-es2015-object-super "^6.24.1"
+ babel-plugin-transform-es2015-parameters "^6.24.1"
+ babel-plugin-transform-es2015-shorthand-properties "^6.24.1"
+ babel-plugin-transform-es2015-spread "^6.22.0"
+ babel-plugin-transform-es2015-sticky-regex "^6.24.1"
+ babel-plugin-transform-es2015-template-literals "^6.22.0"
+ babel-plugin-transform-es2015-typeof-symbol "^6.22.0"
+ babel-plugin-transform-es2015-unicode-regex "^6.24.1"
+ babel-plugin-transform-regenerator "^6.24.1"
+
+babel-preset-flow@^6.23.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz#e71218887085ae9a24b5be4169affb599816c49d"
+ dependencies:
+ babel-plugin-transform-flow-strip-types "^6.22.0"
+
+babel-preset-react@6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-preset-react/-/babel-preset-react-6.24.1.tgz#ba69dfaea45fc3ec639b6a4ecea6e17702c91380"
+ dependencies:
+ babel-plugin-syntax-jsx "^6.3.13"
+ babel-plugin-transform-react-display-name "^6.23.0"
+ babel-plugin-transform-react-jsx "^6.24.1"
+ babel-plugin-transform-react-jsx-self "^6.22.0"
+ babel-plugin-transform-react-jsx-source "^6.22.0"
+ babel-preset-flow "^6.23.0"
+
+babel-preset-stage-2@6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-preset-stage-2/-/babel-preset-stage-2-6.24.1.tgz#d9e2960fb3d71187f0e64eec62bc07767219bdc1"
+ dependencies:
+ babel-plugin-syntax-dynamic-import "^6.18.0"
+ babel-plugin-transform-class-properties "^6.24.1"
+ babel-plugin-transform-decorators "^6.24.1"
+ babel-preset-stage-3 "^6.24.1"
+
+babel-preset-stage-3@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-preset-stage-3/-/babel-preset-stage-3-6.24.1.tgz#836ada0a9e7a7fa37cb138fb9326f87934a48395"
+ dependencies:
+ babel-plugin-syntax-trailing-function-commas "^6.22.0"
+ babel-plugin-transform-async-generator-functions "^6.24.1"
+ babel-plugin-transform-async-to-generator "^6.24.1"
+ babel-plugin-transform-exponentiation-operator "^6.24.1"
+ babel-plugin-transform-object-rest-spread "^6.22.0"
+
+babel-register@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071"
+ dependencies:
+ babel-core "^6.26.0"
+ babel-runtime "^6.26.0"
+ core-js "^2.5.0"
+ home-or-tmp "^2.0.0"
+ lodash "^4.17.4"
+ mkdirp "^0.5.1"
+ source-map-support "^0.4.15"
+
+babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
+ dependencies:
+ core-js "^2.4.0"
+ regenerator-runtime "^0.11.0"
+
+babel-template@^6.24.1, babel-template@^6.26.0, babel-template@^6.3.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ lodash "^4.17.4"
+
+babel-traverse@^6.24.1, babel-traverse@^6.26.0, babel-traverse@^6.4.5, babel-traverse@^6.9.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ babel-messages "^6.23.0"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ debug "^2.6.8"
+ globals "^9.18.0"
+ invariant "^2.2.2"
+ lodash "^4.17.4"
+
+babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
+ dependencies:
+ babel-runtime "^6.26.0"
+ esutils "^2.0.2"
+ lodash "^4.17.4"
+ to-fast-properties "^1.0.3"
+
+babylon@^6.18.0, babylon@^6.8.0:
+ version "6.18.0"
+ resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
+
+bail@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3"
+
+balanced-match@^0.4.2:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
+
+balanced-match@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+
+base64-js@^1.0.2:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886"
+
+base@^0.11.1:
+ version "0.11.2"
+ resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
+ dependencies:
+ cache-base "^1.0.1"
+ class-utils "^0.3.5"
+ component-emitter "^1.2.1"
+ define-property "^1.0.0"
+ isobject "^3.0.1"
+ mixin-deep "^1.2.0"
+ pascalcase "^0.1.1"
+
+bcrypt-pbkdf@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
+ dependencies:
+ tweetnacl "^0.14.3"
+
+beeper@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809"
+
+big.js@^3.1.3:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e"
+
+binary-extensions@^1.0.0:
+ version "1.10.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0"
+
+bindings@^1.2.1:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.3.0.tgz#b346f6ecf6a95f5a815c5839fc7cdb22502f1ed7"
+
+bl@^1.1.2:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.1.tgz#cac328f7bee45730d404b692203fcb590e172d5e"
+ dependencies:
+ readable-stream "^2.0.5"
+
+block-stream@*:
+ version "0.0.9"
+ resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a"
+ dependencies:
+ inherits "~2.0.0"
+
+bluebird@^2.9.34:
+ version "2.11.0"
+ resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1"
+
+bluebird@^3.1.1:
+ version "3.5.0"
+ resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
+
+bluebird@^3.5.1:
+ version "3.5.1"
+ resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
+
+bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
+ version "4.11.8"
+ resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
+
+body@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069"
+ dependencies:
+ continuable-cache "^0.3.1"
+ error "^7.0.0"
+ raw-body "~1.1.0"
+ safe-json-parse "~1.0.1"
+
+boom@4.x.x:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31"
+ dependencies:
+ hoek "4.x.x"
+
+boom@5.x.x:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02"
+ dependencies:
+ hoek "4.x.x"
+
+brace-expansion@^1.0.0, brace-expansion@^1.1.7:
+ version "1.1.11"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+ dependencies:
+ balanced-match "^1.0.0"
+ concat-map "0.0.1"
+
+braces@^1.8.2:
+ version "1.8.5"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
+ dependencies:
+ expand-range "^1.8.1"
+ preserve "^0.2.0"
+ repeat-element "^1.1.2"
+
+braces@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.0.tgz#a46941cb5fb492156b3d6a656e06c35364e3e66e"
+ dependencies:
+ arr-flatten "^1.1.0"
+ array-unique "^0.3.2"
+ define-property "^1.0.0"
+ extend-shallow "^2.0.1"
+ fill-range "^4.0.0"
+ isobject "^3.0.1"
+ repeat-element "^1.1.2"
+ snapdragon "^0.8.1"
+ snapdragon-node "^2.0.1"
+ split-string "^3.0.2"
+ to-regex "^3.0.1"
+
+braces@^2.3.1:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
+ dependencies:
+ arr-flatten "^1.1.0"
+ array-unique "^0.3.2"
+ extend-shallow "^2.0.1"
+ fill-range "^4.0.0"
+ isobject "^3.0.1"
+ repeat-element "^1.1.2"
+ snapdragon "^0.8.1"
+ snapdragon-node "^2.0.1"
+ split-string "^3.0.2"
+ to-regex "^3.0.1"
+
+brorand@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
+
+browserify-aes@^1.0.0, browserify-aes@^1.0.4:
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.8.tgz#c8fa3b1b7585bb7ba77c5560b60996ddec6d5309"
+ dependencies:
+ buffer-xor "^1.0.3"
+ cipher-base "^1.0.0"
+ create-hash "^1.1.0"
+ evp_bytestokey "^1.0.3"
+ inherits "^2.0.1"
+ safe-buffer "^5.0.1"
+
+browserify-cipher@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.0.tgz#9988244874bf5ed4e28da95666dcd66ac8fc363a"
+ dependencies:
+ browserify-aes "^1.0.4"
+ browserify-des "^1.0.0"
+ evp_bytestokey "^1.0.0"
+
+browserify-des@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.0.tgz#daa277717470922ed2fe18594118a175439721dd"
+ dependencies:
+ cipher-base "^1.0.1"
+ des.js "^1.0.0"
+ inherits "^2.0.1"
+
+browserify-rsa@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524"
+ dependencies:
+ bn.js "^4.1.0"
+ randombytes "^2.0.1"
+
+browserify-sign@^4.0.0:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298"
+ dependencies:
+ bn.js "^4.1.1"
+ browserify-rsa "^4.0.0"
+ create-hash "^1.1.0"
+ create-hmac "^1.1.2"
+ elliptic "^6.0.0"
+ inherits "^2.0.1"
+ parse-asn1 "^5.0.0"
+
+browserify-zlib@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d"
+ dependencies:
+ pako "~0.2.0"
+
+browserslist@^1.3.6, browserslist@^1.5.2:
+ version "1.7.7"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9"
+ dependencies:
+ caniuse-db "^1.0.30000639"
+ electron-to-chromium "^1.2.7"
+
+browserslist@^4.1.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.1.1.tgz#328eb4ff1215b12df6589e9ab82f8adaa4fc8cd6"
+ dependencies:
+ caniuse-lite "^1.0.30000884"
+ electron-to-chromium "^1.3.62"
+ node-releases "^1.0.0-alpha.11"
+
+browserslist@~1.3.1:
+ version "1.3.6"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.3.6.tgz#952ff48d56463d3b538f85ef2f8eaddfd284b133"
+ dependencies:
+ caniuse-db "^1.0.30000525"
+
+bser@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719"
+ dependencies:
+ node-int64 "^0.4.0"
+
+buffer-from@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04"
+
+buffer-xor@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
+
+buffer@^4.3.0:
+ version "4.9.1"
+ resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298"
+ dependencies:
+ base64-js "^1.0.2"
+ ieee754 "^1.1.4"
+ isarray "^1.0.0"
+
+bufferstreams@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/bufferstreams/-/bufferstreams-1.0.1.tgz#cfb1ad9568d3ba3cfe935ba9abdd952de88aab2a"
+ dependencies:
+ readable-stream "^1.0.33"
+
+builtin-modules@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
+
+builtin-status-codes@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
+
+bytes@1:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8"
+
+cacache@^10.0.4:
+ version "10.0.4"
+ resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460"
+ dependencies:
+ bluebird "^3.5.1"
+ chownr "^1.0.1"
+ glob "^7.1.2"
+ graceful-fs "^4.1.11"
+ lru-cache "^4.1.1"
+ mississippi "^2.0.0"
+ mkdirp "^0.5.1"
+ move-concurrently "^1.0.1"
+ promise-inflight "^1.0.1"
+ rimraf "^2.6.2"
+ ssri "^5.2.4"
+ unique-filename "^1.1.0"
+ y18n "^4.0.0"
+
+cache-base@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
+ dependencies:
+ collection-visit "^1.0.0"
+ component-emitter "^1.2.1"
+ get-value "^2.0.6"
+ has-value "^1.0.0"
+ isobject "^3.0.1"
+ set-value "^2.0.0"
+ to-object-path "^0.3.0"
+ union-value "^1.0.0"
+ unset-value "^1.0.0"
+
+call-me-maybe@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
+
+caller-path@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f"
+ dependencies:
+ callsites "^0.2.0"
+
+callsites@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca"
+
+camelcase-css@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-1.0.1.tgz#157c4238265f5cf94a1dffde86446552cbf3f705"
+
+camelcase-keys@^4.0.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-4.2.0.tgz#a2aa5fb1af688758259c32c141426d78923b9b77"
+ dependencies:
+ camelcase "^4.1.0"
+ map-obj "^2.0.0"
+ quick-lru "^1.0.0"
+
+camelcase@^1.0.2:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39"
+
+camelcase@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
+
+caniuse-api@^1.5.2:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c"
+ dependencies:
+ browserslist "^1.3.6"
+ caniuse-db "^1.0.30000529"
+ lodash.memoize "^4.1.2"
+ lodash.uniq "^4.5.0"
+
+caniuse-db@^1.0.30000444, caniuse-db@^1.0.30000525, caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000639:
+ version "1.0.30000733"
+ resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000733.tgz#3a625bc41c7a9f99d59d64552857dd1af0edd9d4"
+
+caniuse-lite@^1.0.30000884:
+ version "1.0.30000885"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000885.tgz#e889e9f8e7e50e769f2a49634c932b8aee622984"
+
+caseless@~0.12.0:
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
+
+ccount@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.3.tgz#f1cec43f332e2ea5a569fd46f9f5bde4e6102aff"
+
+center-align@^0.1.1:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad"
+ dependencies:
+ align-text "^0.1.3"
+ lazy-cache "^1.0.3"
+
+chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
+ version "1.1.3"
+ resolved "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
+ dependencies:
+ ansi-styles "^2.2.1"
+ escape-string-regexp "^1.0.2"
+ has-ansi "^2.0.0"
+ strip-ansi "^3.0.0"
+ supports-color "^2.0.0"
+
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
+ dependencies:
+ ansi-styles "^3.2.1"
+ escape-string-regexp "^1.0.5"
+ supports-color "^5.3.0"
+
+change-emitter@^0.1.2:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
+
+character-entities-html4@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.2.tgz#c44fdde3ce66b52e8d321d6c1bf46101f0150610"
+
+character-entities-legacy@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.2.tgz#7c6defb81648498222c9855309953d05f4d63a9c"
+
+character-entities@^1.0.0:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.2.tgz#58c8f371c0774ef0ba9b2aca5f00d8f100e6e363"
+
+character-reference-invalid@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.2.tgz#21e421ad3d84055952dab4a43a04e73cd425d3ed"
+
+chardet@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
+
+charenc@~0.0.1:
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
+
+chokidar@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
+ dependencies:
+ anymatch "^1.3.0"
+ async-each "^1.0.0"
+ glob-parent "^2.0.0"
+ inherits "^2.0.1"
+ is-binary-path "^1.0.0"
+ is-glob "^2.0.0"
+ path-is-absolute "^1.0.0"
+ readdirp "^2.0.0"
+ optionalDependencies:
+ fsevents "^1.0.0"
+
+chokidar@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.0.tgz#6686313c541d3274b2a5c01233342037948c911b"
+ dependencies:
+ anymatch "^2.0.0"
+ async-each "^1.0.0"
+ braces "^2.3.0"
+ glob-parent "^3.1.0"
+ inherits "^2.0.1"
+ is-binary-path "^1.0.0"
+ is-glob "^4.0.0"
+ normalize-path "^2.1.1"
+ path-is-absolute "^1.0.0"
+ readdirp "^2.0.0"
+ optionalDependencies:
+ fsevents "^1.0.0"
+
+chownr@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181"
+
+cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de"
+ dependencies:
+ inherits "^2.0.1"
+ safe-buffer "^5.0.1"
+
+circular-json@^0.3.1:
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66"
+
+clap@^1.0.9:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/clap/-/clap-1.2.2.tgz#683f6f93a320794d129386d74b2a1d2d66fede7e"
+ dependencies:
+ chalk "^1.1.3"
+
+class-utils@^0.3.5:
+ version "0.3.6"
+ resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
+ dependencies:
+ arr-union "^3.1.0"
+ define-property "^0.2.5"
+ isobject "^3.0.0"
+ static-extend "^0.1.1"
+
+classnames@2.2.6:
+ version "2.2.6"
+ resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
+
+classnames@^2.2.0, classnames@^2.2.3:
+ version "2.2.5"
+ resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
+
+clean-css@4.2.1:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17"
+ dependencies:
+ source-map "~0.6.0"
+
+cli-cursor@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
+ dependencies:
+ restore-cursor "^2.0.0"
+
+cli-width@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
+
+clipboard@2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.1.tgz#a12481e1c13d8a50f5f036b0560fe5d16d74e46a"
+ dependencies:
+ good-listener "^1.2.2"
+ select "^1.1.2"
+ tiny-emitter "^2.0.0"
+
+cliui@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1"
+ dependencies:
+ center-align "^0.1.1"
+ right-align "^0.1.1"
+ wordwrap "0.0.2"
+
+cliui@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d"
+ dependencies:
+ string-width "^1.0.1"
+ strip-ansi "^3.0.1"
+ wrap-ansi "^2.0.0"
+
+clone-buffer@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
+
+clone-regexp@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/clone-regexp/-/clone-regexp-1.0.1.tgz#051805cd33173375d82118fc0918606da39fd60f"
+ dependencies:
+ is-regexp "^1.0.0"
+ is-supported-regexp-flag "^1.0.0"
+
+clone-stats@^0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
+
+clone-stats@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
+
+clone@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/clone/-/clone-0.2.0.tgz#c6126a90ad4f72dbf5acdb243cc37724fe93fc1f"
+
+clone@^1.0.0, clone@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149"
+
+clone@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
+
+cloneable-readable@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.0.0.tgz#a6290d413f217a61232f95e458ff38418cfb0117"
+ dependencies:
+ inherits "^2.0.1"
+ process-nextick-args "^1.0.6"
+ through2 "^2.0.1"
+
+co@^4.6.0:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+
+coa@~1.0.1:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.4.tgz#a9ef153660d6a86a8bdec0289a5c684d217432fd"
+ dependencies:
+ q "^1.1.2"
+
+code-point-at@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+
+collapse-white-space@^1.0.2:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.4.tgz#ce05cf49e54c3277ae573036a26851ba430a0091"
+
+collection-visit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
+ dependencies:
+ map-visit "^1.0.0"
+ object-visit "^1.0.0"
+
+color-convert@^1.3.0:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a"
+ dependencies:
+ color-name "^1.1.1"
+
+color-convert@^1.9.0:
+ version "1.9.3"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+ dependencies:
+ color-name "1.1.3"
+
+color-name@1.1.3, color-name@^1.0.0, color-name@^1.1.1:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+
+color-string@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991"
+ dependencies:
+ color-name "^1.0.0"
+
+color-support@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
+
+color@^0.11.0:
+ version "0.11.4"
+ resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764"
+ dependencies:
+ clone "^1.0.2"
+ color-convert "^1.3.0"
+ color-string "^0.3.0"
+
+colormin@^1.0.5:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133"
+ dependencies:
+ color "^0.11.0"
+ css-color-names "0.0.4"
+ has "^1.0.1"
+
+colors@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
+
+combined-stream@^1.0.5, combined-stream@~1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009"
+ dependencies:
+ delayed-stream "~1.0.0"
+
+commander@^2.2.0, commander@^2.8.1:
+ version "2.11.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
+
+commander@~2.13.0:
+ version "2.13.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c"
+
+commondir@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
+
+component-emitter@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
+
+concat-map@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+
+concat-stream@^1.5.0:
+ version "1.6.2"
+ resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
+ dependencies:
+ buffer-from "^1.0.0"
+ inherits "^2.0.3"
+ readable-stream "^2.2.2"
+ typedarray "^0.0.6"
+
+concat-with-sourcemaps@^1.0.0:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.0.4.tgz#f55b3be2aeb47601b10a2d5259ccfb70fd2f1dd6"
+ dependencies:
+ source-map "^0.5.1"
+
+console-browserify@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10"
+ dependencies:
+ date-now "^0.1.4"
+
+console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+
+consolidate@^0.14.1:
+ version "0.14.5"
+ resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.14.5.tgz#5a25047bc76f73072667c8cb52c989888f494c63"
+ dependencies:
+ bluebird "^3.1.1"
+
+constants-browserify@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
+
+continuable-cache@^0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f"
+
+convert-source-map@1.X, convert-source-map@^1.5.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5"
+
+convert-source-map@^1.1.0, convert-source-map@^1.5.1:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20"
+ dependencies:
+ safe-buffer "~5.1.1"
+
+copy-concurrently@^1.0.0:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
+ dependencies:
+ aproba "^1.1.1"
+ fs-write-stream-atomic "^1.0.8"
+ iferr "^0.1.5"
+ mkdirp "^0.5.1"
+ rimraf "^2.5.4"
+ run-queue "^1.0.0"
+
+copy-descriptor@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
+
+core-js@^1.0.0:
+ version "1.2.7"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
+
+core-js@^2.4.0, core-js@^2.5.0:
+ version "2.5.1"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b"
+
+core-util-is@1.0.2, core-util-is@~1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+
+cosmiconfig@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-4.0.0.tgz#760391549580bbd2df1e562bc177b13c290972dc"
+ dependencies:
+ is-directory "^0.3.1"
+ js-yaml "^3.9.0"
+ parse-json "^4.0.0"
+ require-from-string "^2.0.1"
+
+cosmiconfig@^5.0.0:
+ version "5.0.6"
+ resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.6.tgz#dca6cf680a0bd03589aff684700858c81abeeb39"
+ dependencies:
+ is-directory "^0.3.1"
+ js-yaml "^3.9.0"
+ parse-json "^4.0.0"
+
+create-ecdh@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d"
+ dependencies:
+ bn.js "^4.1.0"
+ elliptic "^6.0.0"
+
+create-hash@^1.1.0, create-hash@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd"
+ dependencies:
+ cipher-base "^1.0.1"
+ inherits "^2.0.1"
+ ripemd160 "^2.0.0"
+ sha.js "^2.4.0"
+
+create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06"
+ dependencies:
+ cipher-base "^1.0.3"
+ create-hash "^1.1.0"
+ inherits "^2.0.1"
+ ripemd160 "^2.0.0"
+ safe-buffer "^5.0.1"
+ sha.js "^2.4.8"
+
+create-react-class@15.6.3:
+ version "15.6.3"
+ resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.3.tgz#2d73237fb3f970ae6ebe011a9e66f46dbca80036"
+ dependencies:
+ fbjs "^0.8.9"
+ loose-envify "^1.3.1"
+ object-assign "^4.1.1"
+
+cross-spawn@^5.0.1:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+ dependencies:
+ lru-cache "^4.0.1"
+ shebang-command "^1.2.0"
+ which "^1.2.9"
+
+cross-spawn@^6.0.5:
+ version "6.0.5"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+ dependencies:
+ nice-try "^1.0.4"
+ path-key "^2.0.1"
+ semver "^5.5.0"
+ shebang-command "^1.2.0"
+ which "^1.2.9"
+
+crypt@~0.0.1:
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
+
+cryptiles@3.x.x:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe"
+ dependencies:
+ boom "5.x.x"
+
+crypto-browserify@^3.11.0:
+ version "3.11.1"
+ resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz#948945efc6757a400d6e5e5af47194d10064279f"
+ dependencies:
+ browserify-cipher "^1.0.0"
+ browserify-sign "^4.0.0"
+ create-ecdh "^4.0.0"
+ create-hash "^1.1.0"
+ create-hmac "^1.1.0"
+ diffie-hellman "^5.0.0"
+ inherits "^2.0.1"
+ pbkdf2 "^3.0.3"
+ public-encrypt "^4.0.0"
+ randombytes "^2.0.0"
+
+css-color-names@0.0.4:
+ version "0.0.4"
+ resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
+
+css-loader@0.28.9:
+ version "0.28.9"
+ resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.9.tgz#68064b85f4e271d7ce4c48a58300928e535d1c95"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ css-selector-tokenizer "^0.7.0"
+ cssnano "^3.10.0"
+ icss-utils "^2.1.0"
+ loader-utils "^1.0.2"
+ lodash.camelcase "^4.3.0"
+ object-assign "^4.1.1"
+ postcss "^5.0.6"
+ postcss-modules-extract-imports "^1.2.0"
+ postcss-modules-local-by-default "^1.2.0"
+ postcss-modules-scope "^1.1.0"
+ postcss-modules-values "^1.3.0"
+ postcss-value-parser "^3.3.0"
+ source-list-map "^2.0.0"
+
+css-selector-tokenizer@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz#e6988474ae8c953477bf5e7efecfceccd9cf4c86"
+ dependencies:
+ cssesc "^0.1.0"
+ fastparse "^1.1.1"
+ regexpu-core "^1.0.0"
+
+css@2.X, css@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/css/-/css-2.2.1.tgz#73a4c81de85db664d4ee674f7d47085e3b2d55dc"
+ dependencies:
+ inherits "^2.0.1"
+ source-map "^0.1.38"
+ source-map-resolve "^0.3.0"
+ urix "^0.1.0"
+
+cssesc@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4"
+
+cssnano@^3.10.0:
+ version "3.10.0"
+ resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38"
+ dependencies:
+ autoprefixer "^6.3.1"
+ decamelize "^1.1.2"
+ defined "^1.0.0"
+ has "^1.0.1"
+ object-assign "^4.0.1"
+ postcss "^5.0.14"
+ postcss-calc "^5.2.0"
+ postcss-colormin "^2.1.8"
+ postcss-convert-values "^2.3.4"
+ postcss-discard-comments "^2.0.4"
+ postcss-discard-duplicates "^2.0.1"
+ postcss-discard-empty "^2.0.1"
+ postcss-discard-overridden "^0.1.1"
+ postcss-discard-unused "^2.2.1"
+ postcss-filter-plugins "^2.0.0"
+ postcss-merge-idents "^2.1.5"
+ postcss-merge-longhand "^2.0.1"
+ postcss-merge-rules "^2.0.3"
+ postcss-minify-font-values "^1.0.2"
+ postcss-minify-gradients "^1.0.1"
+ postcss-minify-params "^1.0.4"
+ postcss-minify-selectors "^2.0.4"
+ postcss-normalize-charset "^1.1.0"
+ postcss-normalize-url "^3.0.7"
+ postcss-ordered-values "^2.1.0"
+ postcss-reduce-idents "^2.2.2"
+ postcss-reduce-initial "^1.0.0"
+ postcss-reduce-transforms "^1.0.3"
+ postcss-svgo "^2.1.1"
+ postcss-unique-selectors "^2.0.2"
+ postcss-value-parser "^3.2.3"
+ postcss-zindex "^2.0.1"
+
+csso@~2.3.1:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/csso/-/csso-2.3.2.tgz#ddd52c587033f49e94b71fc55569f252e8ff5f85"
+ dependencies:
+ clap "^1.0.9"
+ source-map "^0.5.3"
+
+currently-unhandled@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
+ dependencies:
+ array-find-index "^1.0.1"
+
+cyclist@~0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
+
+d@1:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"
+ dependencies:
+ es5-ext "^0.10.9"
+
+dashdash@^1.12.0:
+ version "1.14.1"
+ resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
+ dependencies:
+ assert-plus "^1.0.0"
+
+date-now@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
+
+dateformat@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-2.0.0.tgz#2743e3abb5c3fc2462e527dca445e04e9f4dee17"
+
+debug-fabulous@1.X:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/debug-fabulous/-/debug-fabulous-1.0.0.tgz#57f6648646097b1b0849dcda0017362c1ec00f8b"
+ dependencies:
+ debug "3.X"
+ memoizee "0.4.X"
+ object-assign "4.X"
+
+debug@3.X:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+ dependencies:
+ ms "2.0.0"
+
+debug@^0.7.4:
+ version "0.7.4"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39"
+
+debug@^2.1.3, debug@^2.6.8:
+ version "2.6.8"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc"
+ dependencies:
+ ms "2.0.0"
+
+debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
+ version "2.6.9"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ dependencies:
+ ms "2.0.0"
+
+debug@^3.0.0, debug@^3.1.0:
+ version "3.2.5"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.5.tgz#c2418fbfd7a29f4d4f70ff4cea604d4b64c46407"
+ dependencies:
+ ms "^2.1.1"
+
+decamelize-keys@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
+ dependencies:
+ decamelize "^1.1.0"
+ map-obj "^1.0.0"
+
+decamelize@^1.0.0, decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+
+decode-uri-component@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
+
+deep-equal@~1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+
+deep-extend@~0.4.0:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
+
+deep-is@~0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
+
+defaults@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d"
+ dependencies:
+ clone "^1.0.2"
+
+define-properties@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94"
+ dependencies:
+ foreach "^2.0.5"
+ object-keys "^1.0.8"
+
+define-property@^0.2.5:
+ version "0.2.5"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
+ dependencies:
+ is-descriptor "^0.1.0"
+
+define-property@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
+ dependencies:
+ is-descriptor "^1.0.0"
+
+define-property@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
+ dependencies:
+ is-descriptor "^1.0.2"
+ isobject "^3.0.1"
+
+defined@^1.0.0, defined@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
+
+del@3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5"
+ dependencies:
+ globby "^6.1.0"
+ is-path-cwd "^1.0.0"
+ is-path-in-cwd "^1.0.0"
+ p-map "^1.1.1"
+ pify "^3.0.0"
+ rimraf "^2.2.8"
+
+del@^2.0.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8"
+ dependencies:
+ globby "^5.0.0"
+ is-path-cwd "^1.0.0"
+ is-path-in-cwd "^1.0.0"
+ object-assign "^4.0.1"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+ rimraf "^2.2.8"
+
+delayed-stream@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+
+delegate@^3.1.2:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
+
+delegates@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+
+deprecated@^0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/deprecated/-/deprecated-0.0.1.tgz#f9c9af5464afa1e7a971458a8bdef2aa94d5bb19"
+
+des.js@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc"
+ dependencies:
+ inherits "^2.0.1"
+ minimalistic-assert "^1.0.0"
+
+detect-file@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63"
+ dependencies:
+ fs-exists-sync "^0.1.0"
+
+detect-indent@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
+ dependencies:
+ repeating "^2.0.0"
+
+detect-newline@2.X:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
+
+diff@^1.3.2:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf"
+
+diffie-hellman@^5.0.0:
+ version "5.0.2"
+ resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
+ dependencies:
+ bn.js "^4.1.0"
+ miller-rabin "^4.0.0"
+ randombytes "^2.0.0"
+
+dir-glob@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
+ dependencies:
+ arrify "^1.0.1"
+ path-type "^3.0.0"
+
+disparity@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/disparity/-/disparity-2.0.0.tgz#57ddacb47324ae5f58d2cc0da886db4ce9eeb718"
+ dependencies:
+ ansi-styles "^2.0.1"
+ diff "^1.3.2"
+
+dnd-core@^4.0.5:
+ version "4.0.5"
+ resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-4.0.5.tgz#3b83d138d0d5e265c73ec978dec5e1ed441dc665"
+ dependencies:
+ asap "^2.0.6"
+ invariant "^2.2.4"
+ lodash "^4.17.10"
+ redux "^4.0.0"
+
+dnode-protocol@~0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/dnode-protocol/-/dnode-protocol-0.2.2.tgz#51151d16fc3b5f84815ee0b9497a1061d0d1949d"
+ dependencies:
+ jsonify "~0.0.0"
+ traverse "~0.6.3"
+
+dnode@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/dnode/-/dnode-1.2.2.tgz#4ac3cfe26e292b3b39b8258ae7d94edc58132efa"
+ dependencies:
+ dnode-protocol "~0.2.2"
+ jsonify "~0.0.0"
+ optionalDependencies:
+ weak "^1.0.0"
+
+doctrine@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
+ dependencies:
+ esutils "^2.0.2"
+
+dom-css@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/dom-css/-/dom-css-2.1.0.tgz#fdbc2d5a015d0a3e1872e11472bbd0e7b9e6a202"
+ dependencies:
+ add-px-to-style "1.0.0"
+ prefix-style "2.0.1"
+ to-camel-case "1.0.0"
+
+"dom-helpers@^2.4.0 || ^3.0.0":
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
+
+dom-serializer@0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
+ dependencies:
+ domelementtype "~1.1.1"
+ entities "~1.1.1"
+
+domain-browser@^1.1.1:
+ version "1.1.7"
+ resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc"
+
+domelementtype@1, domelementtype@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2"
+
+domelementtype@~1.1.1:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b"
+
+domhandler@^2.3.0:
+ version "2.4.2"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
+ dependencies:
+ domelementtype "1"
+
+domutils@^1.5.1:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
+ dependencies:
+ dom-serializer "0"
+ domelementtype "1"
+
+dot-prop@^4.1.1:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
+ dependencies:
+ is-obj "^1.0.0"
+
+duplexer2@0.0.2:
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db"
+ dependencies:
+ readable-stream "~1.1.9"
+
+duplexer@^0.1.1, duplexer@~0.1.1:
+ version "0.1.1"
+ resolved "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
+
+duplexify@^3.4.2, duplexify@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.6.0.tgz#592903f5d80b38d037220541264d69a198fb3410"
+ dependencies:
+ end-of-stream "^1.0.0"
+ inherits "^2.0.1"
+ readable-stream "^2.0.0"
+ stream-shift "^1.0.0"
+
+ecc-jsbn@~0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505"
+ dependencies:
+ jsbn "~0.1.0"
+
+electron-to-chromium@^1.2.7:
+ version "1.3.21"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.21.tgz#a967ebdcfe8ed0083fc244d1894022a8e8113ea2"
+
+electron-to-chromium@^1.3.62:
+ version "1.3.67"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.67.tgz#5e8f3ffac89b4b0402c7e1a565be06f3a109abbc"
+
+element-class@0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e"
+
+elliptic@^6.0.0:
+ version "6.4.0"
+ resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df"
+ dependencies:
+ bn.js "^4.4.0"
+ brorand "^1.0.1"
+ hash.js "^1.0.0"
+ hmac-drbg "^1.0.0"
+ inherits "^2.0.1"
+ minimalistic-assert "^1.0.0"
+ minimalistic-crypto-utils "^1.0.0"
+
+emojis-list@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
+
+encoding@^0.1.11:
+ version "0.1.12"
+ resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
+ dependencies:
+ iconv-lite "~0.4.13"
+
+end-of-stream@^1.0.0, end-of-stream@^1.1.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
+ dependencies:
+ once "^1.4.0"
+
+end-of-stream@~0.1.5:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-0.1.5.tgz#8e177206c3c80837d85632e8b9359dfe8b2f6eaf"
+ dependencies:
+ once "~1.3.0"
+
+enhanced-resolve@^3.4.0:
+ version "3.4.1"
+ resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
+ dependencies:
+ graceful-fs "^4.1.2"
+ memory-fs "^0.4.0"
+ object-assign "^4.0.1"
+ tapable "^0.2.7"
+
+entities@^1.1.1, entities@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
+
+errno@^0.1.3, errno@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d"
+ dependencies:
+ prr "~0.0.0"
+
+errno@~0.1.7:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
+ dependencies:
+ prr "~1.0.1"
+
+error-ex@^1.2.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc"
+ dependencies:
+ is-arrayish "^0.2.1"
+
+error-ex@^1.3.1:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+ dependencies:
+ is-arrayish "^0.2.1"
+
+error@^7.0.0:
+ version "7.0.2"
+ resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02"
+ dependencies:
+ string-template "~0.2.1"
+ xtend "~4.0.0"
+
+es-abstract@^1.5.0, es-abstract@^1.7.0:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.8.2.tgz#25103263dc4decbda60e0c737ca32313518027ee"
+ dependencies:
+ es-to-primitive "^1.1.1"
+ function-bind "^1.1.1"
+ has "^1.0.1"
+ is-callable "^1.1.3"
+ is-regex "^1.0.4"
+
+es-to-primitive@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.1.1.tgz#45355248a88979034b6792e19bb81f2b7975dd0d"
+ dependencies:
+ is-callable "^1.1.1"
+ is-date-object "^1.0.1"
+ is-symbol "^1.0.1"
+
+es5-ext@^0.10.14, es5-ext@^0.10.30, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2:
+ version "0.10.30"
+ resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.30.tgz#7141a16836697dbabfaaaeee41495ce29f52c939"
+ dependencies:
+ es6-iterator "2"
+ es6-symbol "~3.1"
+
+es6-iterator@2, es6-iterator@^2.0.1, es6-iterator@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512"
+ dependencies:
+ d "1"
+ es5-ext "^0.10.14"
+ es6-symbol "^3.1"
+
+es6-map@^0.1.3:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0"
+ dependencies:
+ d "1"
+ es5-ext "~0.10.14"
+ es6-iterator "~2.0.1"
+ es6-set "~0.1.5"
+ es6-symbol "~3.1.1"
+ event-emitter "~0.3.5"
+
+es6-promise@^3.1.2:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
+
+es6-set@~0.1.5:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
+ dependencies:
+ d "1"
+ es5-ext "~0.10.14"
+ es6-iterator "~2.0.1"
+ es6-symbol "3.1.1"
+ event-emitter "~0.3.5"
+
+es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@^3.1.1, es6-symbol@~3.1, es6-symbol@~3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77"
+ dependencies:
+ d "1"
+ es5-ext "~0.10.14"
+
+es6-weak-map@^2.0.1, es6-weak-map@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.2.tgz#5e3ab32251ffd1538a1f8e5ffa1357772f92d96f"
+ dependencies:
+ d "1"
+ es5-ext "^0.10.14"
+ es6-iterator "^2.0.1"
+ es6-symbol "^3.1.1"
+
+escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+
+escope@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3"
+ dependencies:
+ es6-map "^0.1.3"
+ es6-weak-map "^2.0.1"
+ esrecurse "^4.1.0"
+ estraverse "^4.1.1"
+
+esformatter-parser@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/esformatter-parser/-/esformatter-parser-1.0.0.tgz#0854072d0487539ed39cae38d8a5432c17ec11d3"
+ dependencies:
+ acorn-to-esprima "^2.0.8"
+ babel-traverse "^6.9.0"
+ babylon "^6.8.0"
+ rocambole "^0.7.0"
+
+esformatter@0.10.0:
+ version "0.10.0"
+ resolved "https://registry.yarnpkg.com/esformatter/-/esformatter-0.10.0.tgz#e321ecc3d94083372cdfcf5c6f942cef6fec59d3"
+ dependencies:
+ acorn-to-esprima "^2.0.6"
+ babel-traverse "^6.4.5"
+ debug "^0.7.4"
+ disparity "^2.0.0"
+ esformatter-parser "^1.0.0"
+ glob "^7.0.5"
+ minimatch "^3.0.2"
+ minimist "^1.1.1"
+ mout ">=0.9 <2.0"
+ npm-run "^3.0.0"
+ resolve "^1.1.5"
+ rocambole ">=0.7 <2.0"
+ rocambole-indent "^2.0.4"
+ rocambole-linebreak "^1.0.2"
+ rocambole-node "~1.0"
+ rocambole-token "^1.1.2"
+ rocambole-whitespace "^1.0.0"
+ stdin "*"
+ strip-json-comments "~0.1.1"
+ supports-color "^1.3.1"
+ user-home "^2.0.0"
+
+eslint-plugin-filenames@1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-filenames/-/eslint-plugin-filenames-1.3.2.tgz#7094f00d7aefdd6999e3ac19f72cea058e590cf7"
+ dependencies:
+ lodash.camelcase "4.3.0"
+ lodash.kebabcase "4.1.1"
+ lodash.snakecase "4.1.1"
+ lodash.upperfirst "4.3.1"
+
+eslint-plugin-react@7.11.1:
+ version "7.11.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.11.1.tgz#c01a7af6f17519457d6116aa94fc6d2ccad5443c"
+ dependencies:
+ array-includes "^3.0.3"
+ doctrine "^2.1.0"
+ has "^1.0.3"
+ jsx-ast-utils "^2.0.1"
+ prop-types "^15.6.2"
+
+eslint-scope@3.7.1:
+ version "3.7.1"
+ resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
+ dependencies:
+ esrecurse "^4.1.0"
+ estraverse "^4.1.1"
+
+eslint-scope@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172"
+ dependencies:
+ esrecurse "^4.1.0"
+ estraverse "^4.1.1"
+
+eslint-utils@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512"
+
+eslint-visitor-keys@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
+
+eslint@5.6.0:
+ version "5.6.0"
+ resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.6.0.tgz#b6f7806041af01f71b3f1895cbb20971ea4b6223"
+ dependencies:
+ "@babel/code-frame" "^7.0.0"
+ ajv "^6.5.3"
+ chalk "^2.1.0"
+ cross-spawn "^6.0.5"
+ debug "^3.1.0"
+ doctrine "^2.1.0"
+ eslint-scope "^4.0.0"
+ eslint-utils "^1.3.1"
+ eslint-visitor-keys "^1.0.0"
+ espree "^4.0.0"
+ esquery "^1.0.1"
+ esutils "^2.0.2"
+ file-entry-cache "^2.0.0"
+ functional-red-black-tree "^1.0.1"
+ glob "^7.1.2"
+ globals "^11.7.0"
+ ignore "^4.0.6"
+ imurmurhash "^0.1.4"
+ inquirer "^6.1.0"
+ is-resolvable "^1.1.0"
+ js-yaml "^3.12.0"
+ json-stable-stringify-without-jsonify "^1.0.1"
+ levn "^0.3.0"
+ lodash "^4.17.5"
+ minimatch "^3.0.4"
+ mkdirp "^0.5.1"
+ natural-compare "^1.4.0"
+ optionator "^0.8.2"
+ path-is-inside "^1.0.2"
+ pluralize "^7.0.0"
+ progress "^2.0.0"
+ regexpp "^2.0.0"
+ require-uncached "^1.0.3"
+ semver "^5.5.1"
+ strip-ansi "^4.0.0"
+ strip-json-comments "^2.0.1"
+ table "^4.0.3"
+ text-table "^0.2.0"
+
+espree@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/espree/-/espree-4.0.0.tgz#253998f20a0f82db5d866385799d912a83a36634"
+ dependencies:
+ acorn "^5.6.0"
+ acorn-jsx "^4.1.1"
+
+esprima@^2.1, esprima@^2.6.0:
+ version "2.7.3"
+ resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
+
+esprima@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
+
+esprint@0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.4.0.tgz#f89c9bace36d90407968a8f9ceb0800ff786aab0"
+ dependencies:
+ dnode "^1.2.2"
+ fb-watchman "^2.0.0"
+ glob "^7.1.1"
+ sane "^1.6.0"
+ worker-farm "^1.3.1"
+ yargs "^8.0.1"
+
+esquery@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
+ dependencies:
+ estraverse "^4.0.0"
+
+esrecurse@^4.1.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.0.tgz#fa9568d98d3823f9a41d91e902dcab9ea6e5b163"
+ dependencies:
+ estraverse "^4.1.0"
+ object-assign "^4.0.1"
+
+estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13"
+
+esutils@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
+
+event-emitter@^0.3.5, event-emitter@~0.3.5:
+ version "0.3.5"
+ resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
+ dependencies:
+ d "1"
+ es5-ext "~0.10.14"
+
+event-stream@^3.3.4:
+ version "3.3.6"
+ resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.6.tgz#cac1230890e07e73ec9cacd038f60a5b66173eef"
+ dependencies:
+ duplexer "^0.1.1"
+ flatmap-stream "^0.1.0"
+ from "^0.1.7"
+ map-stream "0.0.7"
+ pause-stream "^0.0.11"
+ split "^1.0.1"
+ stream-combiner "^0.2.2"
+ through "^2.3.8"
+
+events@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
+
+evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02"
+ dependencies:
+ md5.js "^1.3.4"
+ safe-buffer "^5.1.1"
+
+exec-sh@^0.2.0:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.1.tgz#163b98a6e89e6b65b47c2a28d215bc1f63989c38"
+ dependencies:
+ merge "^1.1.3"
+
+execa@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
+ dependencies:
+ cross-spawn "^5.0.1"
+ get-stream "^3.0.0"
+ is-stream "^1.1.0"
+ npm-run-path "^2.0.0"
+ p-finally "^1.0.0"
+ signal-exit "^3.0.0"
+ strip-eof "^1.0.0"
+
+execall@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/execall/-/execall-1.0.0.tgz#73d0904e395b3cab0658b08d09ec25307f29bb73"
+ dependencies:
+ clone-regexp "^1.0.0"
+
+exenv@^1.2.1:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
+
+expand-brackets@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
+ dependencies:
+ is-posix-bracket "^0.1.0"
+
+expand-brackets@^2.1.4:
+ version "2.1.4"
+ resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
+ dependencies:
+ debug "^2.3.3"
+ define-property "^0.2.5"
+ extend-shallow "^2.0.1"
+ posix-character-classes "^0.1.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+expand-range@^1.8.1:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
+ dependencies:
+ fill-range "^2.1.0"
+
+expand-tilde@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449"
+ dependencies:
+ os-homedir "^1.0.1"
+
+expand-tilde@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
+ dependencies:
+ homedir-polyfill "^1.0.1"
+
+extend-shallow@^1.1.2:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-1.1.4.tgz#19d6bf94dfc09d76ba711f39b872d21ff4dd9071"
+ dependencies:
+ kind-of "^1.1.0"
+
+extend-shallow@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
+ dependencies:
+ is-extendable "^0.1.0"
+
+extend-shallow@^3.0.0, extend-shallow@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
+ dependencies:
+ assign-symbols "^1.0.0"
+ is-extendable "^1.0.1"
+
+extend@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+
+extend@~3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
+
+external-editor@^3.0.0:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27"
+ dependencies:
+ chardet "^0.7.0"
+ iconv-lite "^0.4.24"
+ tmp "^0.0.33"
+
+extglob@^0.3.1:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
+ dependencies:
+ is-extglob "^1.0.0"
+
+extglob@^2.0.2, extglob@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
+ dependencies:
+ array-unique "^0.3.2"
+ define-property "^1.0.0"
+ expand-brackets "^2.1.4"
+ extend-shallow "^2.0.1"
+ fragment-cache "^0.2.1"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+extract-text-webpack-plugin@3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.2.tgz#5f043eaa02f9750a9258b78c0a6e0dc1408fb2f7"
+ dependencies:
+ async "^2.4.1"
+ loader-utils "^1.1.0"
+ schema-utils "^0.3.0"
+ webpack-sources "^1.0.1"
+
+extsprintf@1.3.0, extsprintf@^1.2.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+
+fancy-log@1.3.2, fancy-log@^1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.2.tgz#f41125e3d84f2e7d89a43d06d958c8f78be16be1"
+ dependencies:
+ ansi-gray "^0.1.1"
+ color-support "^1.1.3"
+ time-stamp "^1.0.0"
+
+fancy-log@^1.1.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.0.tgz#45be17d02bb9917d60ccffd4995c999e6c8c9948"
+ dependencies:
+ chalk "^1.1.1"
+ time-stamp "^1.0.0"
+
+fast-deep-equal@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
+
+fast-deep-equal@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
+
+fast-glob@^2.0.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.2.tgz#71723338ac9b4e0e2fff1d6748a2a13d5ed352bf"
+ dependencies:
+ "@mrmlnc/readdir-enhanced" "^2.2.1"
+ "@nodelib/fs.stat" "^1.0.1"
+ glob-parent "^3.1.0"
+ is-glob "^4.0.0"
+ merge2 "^1.2.1"
+ micromatch "^3.1.10"
+
+fast-json-stable-stringify@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
+
+fast-levenshtein@~2.0.4:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+
+fastparse@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8"
+
+faye-websocket@~0.10.0:
+ version "0.10.0"
+ resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4"
+ dependencies:
+ websocket-driver ">=0.5.1"
+
+fb-watchman@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58"
+ dependencies:
+ bser "^2.0.0"
+
+fbjs@^0.8.1, fbjs@^0.8.16:
+ version "0.8.17"
+ resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
+ dependencies:
+ core-js "^1.0.0"
+ isomorphic-fetch "^2.1.1"
+ loose-envify "^1.0.0"
+ object-assign "^4.1.0"
+ promise "^7.1.1"
+ setimmediate "^1.0.5"
+ ua-parser-js "^0.7.18"
+
+fbjs@^0.8.4, fbjs@^0.8.9:
+ version "0.8.15"
+ resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.15.tgz#4f0695fdfcc16c37c0b07facec8cb4c4091685b9"
+ dependencies:
+ core-js "^1.0.0"
+ isomorphic-fetch "^2.1.1"
+ loose-envify "^1.0.0"
+ object-assign "^4.1.0"
+ promise "^7.1.1"
+ setimmediate "^1.0.5"
+ ua-parser-js "^0.7.9"
+
+figures@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
+ dependencies:
+ escape-string-regexp "^1.0.5"
+
+file-entry-cache@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361"
+ dependencies:
+ flat-cache "^1.2.1"
+ object-assign "^4.0.1"
+
+file-loader@1.1.6:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-1.1.6.tgz#7b9a8f2c58f00a77fddf49e940f7ac978a3ea0e8"
+ dependencies:
+ loader-utils "^1.0.2"
+ schema-utils "^0.3.0"
+
+filename-regex@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
+
+filesize@3.6.1:
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317"
+
+fill-range@^2.1.0:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
+ dependencies:
+ is-number "^2.1.0"
+ isobject "^2.0.0"
+ randomatic "^3.0.0"
+ repeat-element "^1.1.2"
+ repeat-string "^1.5.2"
+
+fill-range@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-number "^3.0.0"
+ repeat-string "^1.6.1"
+ to-regex-range "^2.1.0"
+
+find-cache-dir@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f"
+ dependencies:
+ commondir "^1.0.1"
+ make-dir "^1.0.0"
+ pkg-dir "^2.0.0"
+
+find-index@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/find-index/-/find-index-0.1.1.tgz#675d358b2ca3892d795a1ab47232f8b6e2e0dde4"
+
+find-up@^2.0.0, find-up@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
+ dependencies:
+ locate-path "^2.0.0"
+
+findup-sync@^0.4.2:
+ version "0.4.3"
+ resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12"
+ dependencies:
+ detect-file "^0.1.0"
+ is-glob "^2.0.1"
+ micromatch "^2.3.7"
+ resolve-dir "^0.1.0"
+
+fined@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/fined/-/fined-1.1.0.tgz#b37dc844b76a2f5e7081e884f7c0ae344f153476"
+ dependencies:
+ expand-tilde "^2.0.2"
+ is-plain-object "^2.0.3"
+ object.defaults "^1.1.0"
+ object.pick "^1.2.0"
+ parse-filepath "^1.0.1"
+
+first-chunk-stream@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
+
+first-chunk-stream@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70"
+ dependencies:
+ readable-stream "^2.0.2"
+
+flagged-respawn@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-0.3.2.tgz#ff191eddcd7088a675b2610fffc976be9b8074b5"
+
+flat-cache@^1.2.1:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.0.tgz#d3030b32b38154f4e3b7e9c709f490f7ef97c481"
+ dependencies:
+ circular-json "^0.3.1"
+ del "^2.0.2"
+ graceful-fs "^4.1.2"
+ write "^0.2.1"
+
+flatmap-stream@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/flatmap-stream/-/flatmap-stream-0.1.0.tgz#ed54e01422cd29281800914fcb968d58b685d5f1"
+
+flatten@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
+
+flush-write-stream@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd"
+ dependencies:
+ inherits "^2.0.1"
+ readable-stream "^2.0.4"
+
+for-each@~0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4"
+ dependencies:
+ is-function "~1.0.0"
+
+for-in@^1.0.1, for-in@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
+
+for-own@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
+ dependencies:
+ for-in "^1.0.1"
+
+for-own@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b"
+ dependencies:
+ for-in "^1.0.1"
+
+foreach@^2.0.5:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99"
+
+forever-agent@~0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+
+form-data@~2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf"
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.5"
+ mime-types "^2.1.12"
+
+fragment-cache@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
+ dependencies:
+ map-cache "^0.2.2"
+
+from2@^2.1.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af"
+ dependencies:
+ inherits "^2.0.1"
+ readable-stream "^2.0.0"
+
+from@^0.1.7:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"
+
+fs-exists-sync@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
+
+fs-readfile-promise@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz#80023823981f9ffffe01609e8be668f69ae49e70"
+ dependencies:
+ graceful-fs "^4.1.2"
+
+fs-write-stream-atomic@^1.0.8:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9"
+ dependencies:
+ graceful-fs "^4.1.2"
+ iferr "^0.1.5"
+ imurmurhash "^0.1.4"
+ readable-stream "1 || 2"
+
+fs.realpath@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+
+fsevents@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.2.tgz#3282b713fb3ad80ede0e9fcf4611b5aa6fc033f4"
+ dependencies:
+ nan "^2.3.0"
+ node-pre-gyp "^0.6.36"
+
+fstream-ignore@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105"
+ dependencies:
+ fstream "^1.0.0"
+ inherits "2"
+ minimatch "^3.0.0"
+
+fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2, fstream@^1.0.8:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171"
+ dependencies:
+ graceful-fs "^4.1.2"
+ inherits "~2.0.0"
+ mkdirp ">=0.5 0"
+ rimraf "2"
+
+function-bind@^1.0.2, function-bind@^1.1.1, function-bind@~1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+
+functional-red-black-tree@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
+
+gauge@~2.7.3:
+ version "2.7.4"
+ resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+ dependencies:
+ aproba "^1.0.3"
+ console-control-strings "^1.0.0"
+ has-unicode "^2.0.0"
+ object-assign "^4.1.0"
+ signal-exit "^3.0.0"
+ string-width "^1.0.1"
+ strip-ansi "^3.0.1"
+ wide-align "^1.1.0"
+
+gaze@^0.5.1:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/gaze/-/gaze-0.5.2.tgz#40b709537d24d1d45767db5a908689dfe69ac44f"
+ dependencies:
+ globule "~0.1.0"
+
+get-caller-file@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
+
+get-node-dimensions@^1.2.0:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/get-node-dimensions/-/get-node-dimensions-1.2.2.tgz#7a71e8624cf9e1ab74599bb05b7e5116e995e45b"
+
+get-stdin@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
+
+get-stream@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+
+get-value@^2.0.3, get-value@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
+
+getpass@^0.1.1:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
+ dependencies:
+ assert-plus "^1.0.0"
+
+glob-base@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
+ dependencies:
+ glob-parent "^2.0.0"
+ is-glob "^2.0.0"
+
+glob-parent@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
+ dependencies:
+ is-glob "^2.0.0"
+
+glob-parent@^3.0.1, glob-parent@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
+ dependencies:
+ is-glob "^3.1.0"
+ path-dirname "^1.0.0"
+
+glob-stream@^3.1.5:
+ version "3.1.18"
+ resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-3.1.18.tgz#9170a5f12b790306fdfe598f313f8f7954fd143b"
+ dependencies:
+ glob "^4.3.1"
+ glob2base "^0.0.12"
+ minimatch "^2.0.1"
+ ordered-read-streams "^0.1.0"
+ through2 "^0.6.1"
+ unique-stream "^1.0.0"
+
+glob-to-regexp@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
+
+glob-watcher@^0.0.6:
+ version "0.0.6"
+ resolved "https://registry.yarnpkg.com/glob-watcher/-/glob-watcher-0.0.6.tgz#b95b4a8df74b39c83298b0c05c978b4d9a3b710b"
+ dependencies:
+ gaze "^0.5.1"
+
+glob2base@^0.0.12:
+ version "0.0.12"
+ resolved "https://registry.yarnpkg.com/glob2base/-/glob2base-0.0.12.tgz#9d419b3e28f12e83a362164a277055922c9c0d56"
+ dependencies:
+ find-index "^0.1.1"
+
+glob@^4.3.1:
+ version "4.5.3"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-4.5.3.tgz#c6cb73d3226c1efef04de3c56d012f03377ee15f"
+ dependencies:
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^2.0.1"
+ once "^1.3.0"
+
+glob@^7.0.3, glob@^7.1.1, glob@~7.1.2:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^3.0.4"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
+glob@^7.0.5, glob@^7.1.2:
+ version "7.1.3"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^3.0.4"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
+glob@~3.1.21:
+ version "3.1.21"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-3.1.21.tgz#d29e0a055dea5138f4d07ed40e8982e83c2066cd"
+ dependencies:
+ graceful-fs "~1.2.0"
+ inherits "1"
+ minimatch "~0.2.11"
+
+global-modules@^0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
+ dependencies:
+ global-prefix "^0.1.4"
+ is-windows "^0.2.0"
+
+global-prefix@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f"
+ dependencies:
+ homedir-polyfill "^1.0.0"
+ ini "^1.3.4"
+ is-windows "^0.2.0"
+ which "^1.2.12"
+
+globals@^11.1.0, globals@^11.7.0:
+ version "11.7.0"
+ resolved "https://registry.yarnpkg.com/globals/-/globals-11.7.0.tgz#a583faa43055b1aca771914bf68258e2fc125673"
+
+globals@^9.18.0:
+ version "9.18.0"
+ resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
+
+globby@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d"
+ dependencies:
+ array-union "^1.0.1"
+ arrify "^1.0.0"
+ glob "^7.0.3"
+ object-assign "^4.0.1"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+
+globby@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"
+ dependencies:
+ array-union "^1.0.1"
+ glob "^7.0.3"
+ object-assign "^4.0.1"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+
+globby@^8.0.0:
+ version "8.0.1"
+ resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.1.tgz#b5ad48b8aa80b35b814fc1281ecc851f1d2b5b50"
+ dependencies:
+ array-union "^1.0.1"
+ dir-glob "^2.0.0"
+ fast-glob "^2.0.2"
+ glob "^7.1.2"
+ ignore "^3.3.5"
+ pify "^3.0.0"
+ slash "^1.0.0"
+
+globjoin@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43"
+
+globule@~0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/globule/-/globule-0.1.0.tgz#d9c8edde1da79d125a151b79533b978676346ae5"
+ dependencies:
+ glob "~3.1.21"
+ lodash "~1.0.1"
+ minimatch "~0.2.11"
+
+glogg@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.0.tgz#7fe0f199f57ac906cf512feead8f90ee4a284fc5"
+ dependencies:
+ sparkles "^1.0.0"
+
+gonzales-pe@^4.2.3:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/gonzales-pe/-/gonzales-pe-4.2.3.tgz#41091703625433285e0aee3aa47829fc1fbeb6f2"
+ dependencies:
+ minimist "1.1.x"
+
+good-listener@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
+ dependencies:
+ delegate "^3.1.2"
+
+graceful-fs@4.X, graceful-fs@^4.1.11, graceful-fs@^4.1.2:
+ version "4.1.11"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
+
+graceful-fs@^3.0.0:
+ version "3.0.11"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-3.0.11.tgz#7613c778a1afea62f25c630a086d7f3acbbdd818"
+ dependencies:
+ natives "^1.1.0"
+
+graceful-fs@~1.2.0:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-1.2.3.tgz#15a4806a57547cb2d2dbf27f42e89a8c3451b364"
+
+gulp-cached@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/gulp-cached/-/gulp-cached-1.1.1.tgz#fe7cd4f87f37601e6073cfedee5c2bdaf8b6acce"
+ dependencies:
+ lodash.defaults "^4.2.0"
+ through2 "^2.0.1"
+
+gulp-clean-css@3.10.0:
+ version "3.10.0"
+ resolved "https://registry.yarnpkg.com/gulp-clean-css/-/gulp-clean-css-3.10.0.tgz#bccd4605eff104bfa4980014cc4b3c24c571736d"
+ dependencies:
+ clean-css "4.2.1"
+ plugin-error "1.0.1"
+ through2 "2.0.3"
+ vinyl-sourcemaps-apply "0.2.1"
+
+gulp-concat@2.6.1:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/gulp-concat/-/gulp-concat-2.6.1.tgz#633d16c95d88504628ad02665663cee5a4793353"
+ dependencies:
+ concat-with-sourcemaps "^1.0.0"
+ through2 "^2.0.0"
+ vinyl "^2.0.0"
+
+gulp-declare@0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/gulp-declare/-/gulp-declare-0.3.0.tgz#86830fc6faa88e06382162c8664b8e94957afcd9"
+ dependencies:
+ nsdeclare "^0.1.0"
+ vinyl-map "^1.0.1"
+ xtend "^4.0.0"
+
+gulp-livereload@4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/gulp-livereload/-/gulp-livereload-4.0.0.tgz#be4a6b01731a93f76f4fb29c9e62e323affe7d03"
+ dependencies:
+ chalk "^2.4.1"
+ debug "^3.1.0"
+ event-stream "^3.3.4"
+ fancy-log "^1.3.2"
+ lodash.assign "^4.2.0"
+ tiny-lr "^1.1.1"
+ vinyl "^2.2.0"
+
+gulp-postcss@8.0.0:
+ version "8.0.0"
+ resolved "https://registry.yarnpkg.com/gulp-postcss/-/gulp-postcss-8.0.0.tgz#8d3772cd4d27bca55ec8cb4c8e576e3bde4dc550"
+ dependencies:
+ fancy-log "^1.3.2"
+ plugin-error "^1.0.1"
+ postcss "^7.0.2"
+ postcss-load-config "^2.0.0"
+ vinyl-sourcemaps-apply "^0.2.1"
+
+gulp-print@5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/gulp-print/-/gulp-print-5.0.0.tgz#0fa2791dc4589633f4015f054e4f39a8d285120b"
+ dependencies:
+ ansi-colors "^1.0.1"
+ fancy-log "^1.3.2"
+ map-stream "0.0.7"
+ vinyl "^2.1.0"
+
+gulp-sourcemaps@2.6.4:
+ version "2.6.4"
+ resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-2.6.4.tgz#cbb2008450b1bcce6cd23bf98337be751bf6e30a"
+ dependencies:
+ "@gulp-sourcemaps/identity-map" "1.X"
+ "@gulp-sourcemaps/map-sources" "1.X"
+ acorn "5.X"
+ convert-source-map "1.X"
+ css "2.X"
+ debug-fabulous "1.X"
+ detect-newline "2.X"
+ graceful-fs "4.X"
+ source-map "~0.6.0"
+ strip-bom-string "1.X"
+ through2 "2.X"
+
+gulp-stripbom@1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/gulp-stripbom/-/gulp-stripbom-1.0.4.tgz#58c1d03e85e008a7aab47d81b1297c8c1bc828eb"
+ dependencies:
+ gulp-util "^3.0.0"
+ log-symbols "^1.0.0"
+ strip-bom "^1.0.0"
+ through2 "^0.5.1"
+
+gulp-util@3.0.8, gulp-util@^3.0.0, gulp-util@^3.0.7:
+ version "3.0.8"
+ resolved "https://registry.yarnpkg.com/gulp-util/-/gulp-util-3.0.8.tgz#0054e1e744502e27c04c187c3ecc505dd54bbb4f"
+ dependencies:
+ array-differ "^1.0.0"
+ array-uniq "^1.0.2"
+ beeper "^1.0.0"
+ chalk "^1.0.0"
+ dateformat "^2.0.0"
+ fancy-log "^1.1.0"
+ gulplog "^1.0.0"
+ has-gulplog "^0.1.0"
+ lodash._reescape "^3.0.0"
+ lodash._reevaluate "^3.0.0"
+ lodash._reinterpolate "^3.0.0"
+ lodash.template "^3.0.0"
+ minimist "^1.1.0"
+ multipipe "^0.1.2"
+ object-assign "^3.0.0"
+ replace-ext "0.0.1"
+ through2 "^2.0.0"
+ vinyl "^0.5.0"
+
+gulp-watch@5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/gulp-watch/-/gulp-watch-5.0.1.tgz#83d378752f5bfb46da023e73c17ed1da7066215d"
+ dependencies:
+ ansi-colors "1.1.0"
+ anymatch "^1.3.0"
+ chokidar "^2.0.0"
+ fancy-log "1.3.2"
+ glob-parent "^3.0.1"
+ object-assign "^4.1.0"
+ path-is-absolute "^1.0.1"
+ plugin-error "1.0.1"
+ readable-stream "^2.2.2"
+ slash "^1.0.0"
+ vinyl "^2.1.0"
+ vinyl-file "^2.0.0"
+
+gulp-wrap@0.14.0:
+ version "0.14.0"
+ resolved "https://registry.yarnpkg.com/gulp-wrap/-/gulp-wrap-0.14.0.tgz#15a5c2048e2721e70539a61baf1c34a0bc5f2729"
+ dependencies:
+ consolidate "^0.14.1"
+ es6-promise "^3.1.2"
+ fs-readfile-promise "^2.0.1"
+ js-yaml "^3.2.6"
+ lodash "^4.11.1"
+ node.extend "^1.1.2"
+ plugin-error "^0.1.2"
+ through2 "^2.0.1"
+ tryit "^1.0.1"
+ vinyl-bufferstream "^1.0.1"
+
+gulp@3.9.1:
+ version "3.9.1"
+ resolved "https://registry.yarnpkg.com/gulp/-/gulp-3.9.1.tgz#571ce45928dd40af6514fc4011866016c13845b4"
+ dependencies:
+ archy "^1.0.0"
+ chalk "^1.0.0"
+ deprecated "^0.0.1"
+ gulp-util "^3.0.0"
+ interpret "^1.0.0"
+ liftoff "^2.1.0"
+ minimist "^1.1.0"
+ orchestrator "^0.3.0"
+ pretty-hrtime "^1.0.0"
+ semver "^4.1.0"
+ tildify "^1.0.0"
+ v8flags "^2.0.2"
+ vinyl-fs "^0.3.0"
+
+gulplog@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/gulplog/-/gulplog-1.0.0.tgz#e28c4d45d05ecbbed818363ce8f9c5926229ffe5"
+ dependencies:
+ glogg "^1.0.0"
+
+har-schema@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+
+har-validator@~5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd"
+ dependencies:
+ ajv "^5.1.0"
+ har-schema "^2.0.0"
+
+has-ansi@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
+ dependencies:
+ ansi-regex "^2.0.0"
+
+has-flag@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
+
+has-flag@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
+
+has-flag@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+
+has-gulplog@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/has-gulplog/-/has-gulplog-0.1.0.tgz#6414c82913697da51590397dafb12f22967811ce"
+ dependencies:
+ sparkles "^1.0.0"
+
+has-unicode@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+
+has-value@^0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
+ dependencies:
+ get-value "^2.0.3"
+ has-values "^0.1.4"
+ isobject "^2.0.0"
+
+has-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
+ dependencies:
+ get-value "^2.0.6"
+ has-values "^1.0.0"
+ isobject "^3.0.0"
+
+has-values@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
+
+has-values@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
+ dependencies:
+ is-number "^3.0.0"
+ kind-of "^4.0.0"
+
+has@^1.0.1, has@~1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28"
+ dependencies:
+ function-bind "^1.0.2"
+
+has@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+ dependencies:
+ function-bind "^1.1.1"
+
+hash-base@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-2.0.2.tgz#66ea1d856db4e8a5470cadf6fce23ae5244ef2e1"
+ dependencies:
+ inherits "^2.0.1"
+
+hash-base@^3.0.0:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918"
+ dependencies:
+ inherits "^2.0.1"
+ safe-buffer "^5.0.1"
+
+hash.js@^1.0.0, hash.js@^1.0.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.3.tgz#340dedbe6290187151c1ea1d777a3448935df846"
+ dependencies:
+ inherits "^2.0.3"
+ minimalistic-assert "^1.0.0"
+
+hawk@~6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038"
+ dependencies:
+ boom "4.x.x"
+ cryptiles "3.x.x"
+ hoek "4.x.x"
+ sntp "2.x.x"
+
+history@4.7.2, history@^4.7.2:
+ version "4.7.2"
+ resolved "https://registry.yarnpkg.com/history/-/history-4.7.2.tgz#22b5c7f31633c5b8021c7f4a8a954ac139ee8d5b"
+ dependencies:
+ invariant "^2.2.1"
+ loose-envify "^1.2.0"
+ resolve-pathname "^2.2.0"
+ value-equal "^0.4.0"
+ warning "^3.0.0"
+
+history@^4.5.1:
+ version "4.6.3"
+ resolved "https://registry.yarnpkg.com/history/-/history-4.6.3.tgz#6d723a8712c581d6bef37e8c26f4aedc6eb86967"
+ dependencies:
+ invariant "^2.2.1"
+ loose-envify "^1.2.0"
+ resolve-pathname "^2.0.0"
+ value-equal "^0.2.0"
+ warning "^3.0.0"
+
+hmac-drbg@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
+ dependencies:
+ hash.js "^1.0.3"
+ minimalistic-assert "^1.0.0"
+ minimalistic-crypto-utils "^1.0.1"
+
+hoek@4.x.x:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d"
+
+hoist-non-react-statics@^2.3.0:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0"
+
+hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0:
+ version "2.5.5"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
+
+hoist-non-react-statics@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.0.1.tgz#fba3e7df0210eb9447757ca1a7cb607162f0a364"
+ dependencies:
+ react-is "^16.3.2"
+
+home-or-tmp@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
+ dependencies:
+ os-homedir "^1.0.0"
+ os-tmpdir "^1.0.1"
+
+homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz#4c2bbc8a758998feebf5ed68580f76d46768b4bc"
+ dependencies:
+ parse-passwd "^1.0.0"
+
+hosted-git-info@^2.1.4:
+ version "2.7.1"
+ resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
+
+html-comment-regex@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
+
+html-tags@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-2.0.0.tgz#10b30a386085f43cede353cc8fa7cb0deeea668b"
+
+htmlparser2@^3.9.2:
+ version "3.9.2"
+ resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338"
+ dependencies:
+ domelementtype "^1.3.0"
+ domhandler "^2.3.0"
+ domutils "^1.5.1"
+ entities "^1.1.1"
+ inherits "^2.0.1"
+ readable-stream "^2.0.2"
+
+http-parser-js@>=0.4.0:
+ version "0.4.6"
+ resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.6.tgz#195273f58704c452d671076be201329dd341dc55"
+
+http-signature@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+ dependencies:
+ assert-plus "^1.0.0"
+ jsprim "^1.2.2"
+ sshpk "^1.7.0"
+
+https-browserify@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82"
+
+humps@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
+
+iconv-lite@^0.4.24, iconv-lite@~0.4.13:
+ version "0.4.24"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+ dependencies:
+ safer-buffer ">= 2.1.2 < 3"
+
+icss-replace-symbols@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
+
+icss-utils@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962"
+ dependencies:
+ postcss "^6.0.1"
+
+ieee754@^1.1.4:
+ version "1.1.8"
+ resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
+
+iferr@^0.1.5:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
+
+ignore@^3.3.5:
+ version "3.3.10"
+ resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
+
+ignore@^4.0.0, ignore@^4.0.6:
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
+
+import-cwd@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
+ dependencies:
+ import-from "^2.1.0"
+
+import-from@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1"
+ dependencies:
+ resolve-from "^3.0.0"
+
+import-lazy@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-3.1.0.tgz#891279202c8a2280fdbd6674dbd8da1a1dfc67cc"
+
+imurmurhash@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+
+indent-string@^3.0.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289"
+
+indexes-of@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
+
+indexof@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
+
+inflight@^1.0.4:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+ dependencies:
+ once "^1.3.0"
+ wrappy "1"
+
+inherits@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-1.0.2.tgz#ca4309dadee6b54cc0b8d247e8d7c7a0975bdc9b"
+
+inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+
+inherits@2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1"
+
+ini@^1.3.4, ini@~1.3.0:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e"
+
+inquirer@^6.1.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8"
+ dependencies:
+ ansi-escapes "^3.0.0"
+ chalk "^2.0.0"
+ cli-cursor "^2.1.0"
+ cli-width "^2.0.0"
+ external-editor "^3.0.0"
+ figures "^2.0.0"
+ lodash "^4.17.10"
+ mute-stream "0.0.7"
+ run-async "^2.2.0"
+ rxjs "^6.1.0"
+ string-width "^2.1.0"
+ strip-ansi "^4.0.0"
+ through "^2.3.6"
+
+interpret@^1.0.0:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0"
+
+invariant@^2.0.0, invariant@^2.1.0, invariant@^2.2.1, invariant@^2.2.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360"
+ dependencies:
+ loose-envify "^1.0.0"
+
+invariant@^2.2.4:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+ dependencies:
+ loose-envify "^1.0.0"
+
+invert-kv@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
+
+is-absolute-url@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
+
+is-absolute@^0.2.3:
+ version "0.2.6"
+ resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-0.2.6.tgz#20de69f3db942ef2d87b9c2da36f172235b1b5eb"
+ dependencies:
+ is-relative "^0.2.1"
+ is-windows "^0.2.0"
+
+is-accessor-descriptor@^0.1.6:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-accessor-descriptor@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
+ dependencies:
+ kind-of "^6.0.0"
+
+is-alphabetical@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.2.tgz#1fa6e49213cb7885b75d15862fb3f3d96c884f41"
+
+is-alphanumeric@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz#4a9cef71daf4c001c1d81d63d140cf53fd6889f4"
+
+is-alphanumerical@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.2.tgz#1138e9ae5040158dc6ff76b820acd6b7a181fd40"
+ dependencies:
+ is-alphabetical "^1.0.0"
+ is-decimal "^1.0.0"
+
+is-arrayish@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+
+is-binary-path@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
+ dependencies:
+ binary-extensions "^1.0.0"
+
+is-buffer@^1.1.4, is-buffer@^1.1.5, is-buffer@~1.1.1:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+
+is-builtin-module@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe"
+ dependencies:
+ builtin-modules "^1.0.0"
+
+is-callable@^1.1.1, is-callable@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
+
+is-data-descriptor@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-data-descriptor@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
+ dependencies:
+ kind-of "^6.0.0"
+
+is-date-object@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
+
+is-decimal@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.2.tgz#894662d6a8709d307f3a276ca4339c8fa5dff0ff"
+
+is-descriptor@^0.1.0:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
+ dependencies:
+ is-accessor-descriptor "^0.1.6"
+ is-data-descriptor "^0.1.4"
+ kind-of "^5.0.0"
+
+is-descriptor@^1.0.0, is-descriptor@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
+ dependencies:
+ is-accessor-descriptor "^1.0.0"
+ is-data-descriptor "^1.0.0"
+ kind-of "^6.0.2"
+
+is-directory@^0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1"
+
+is-dotfile@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
+
+is-equal-shallow@^0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
+ dependencies:
+ is-primitive "^2.0.0"
+
+is-extendable@^0.1.0, is-extendable@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
+
+is-extendable@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
+ dependencies:
+ is-plain-object "^2.0.4"
+
+is-extglob@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
+
+is-extglob@^2.1.0, is-extglob@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+
+is-finite@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
+ dependencies:
+ number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+ dependencies:
+ number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+
+is-function@~1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5"
+
+is-glob@^2.0.0, is-glob@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
+ dependencies:
+ is-extglob "^1.0.0"
+
+is-glob@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
+ dependencies:
+ is-extglob "^2.1.0"
+
+is-glob@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0"
+ dependencies:
+ is-extglob "^2.1.1"
+
+is-hexadecimal@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835"
+
+is-number@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-number@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-number@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+
+is-obj@^1.0.0:
+ version "1.0.1"
+ resolved "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
+
+is-odd@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-1.0.0.tgz#3b8a932eb028b3775c39bb09e91767accdb69088"
+ dependencies:
+ is-number "^3.0.0"
+
+is-path-cwd@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
+
+is-path-in-cwd@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52"
+ dependencies:
+ is-path-inside "^1.0.0"
+
+is-path-inside@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
+ dependencies:
+ path-is-inside "^1.0.1"
+
+is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
+
+is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+ dependencies:
+ isobject "^3.0.1"
+
+is-posix-bracket@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
+
+is-primitive@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+
+is-promise@^2.1, is-promise@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
+
+is-regex@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491"
+ dependencies:
+ has "^1.0.1"
+
+is-regexp@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
+
+is-relative@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-0.2.1.tgz#d27f4c7d516d175fb610db84bbeef23c3bc97aa5"
+ dependencies:
+ is-unc-path "^0.1.1"
+
+is-resolvable@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88"
+
+is-stream@^1.0.1, is-stream@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
+
+is-supported-regexp-flag@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.1.tgz#21ee16518d2c1dd3edd3e9a0d57e50207ac364ca"
+
+is-svg@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-2.1.0.tgz#cf61090da0d9efbcab8722deba6f032208dbb0e9"
+ dependencies:
+ html-comment-regex "^1.1.0"
+
+is-symbol@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.1.tgz#3cc59f00025194b6ab2e38dbae6689256b660572"
+
+is-typedarray@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+
+is-unc-path@^0.1.1:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-0.1.2.tgz#6ab053a72573c10250ff416a3814c35178af39b9"
+ dependencies:
+ unc-path-regex "^0.1.0"
+
+is-utf8@^0.2.0:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
+
+is-whitespace-character@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed"
+
+is-windows@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c"
+
+is-windows@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
+
+is-word-character@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.2.tgz#46a5dac3f2a1840898b91e576cd40d493f3ae553"
+
+is@^3.1.0:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/is/-/is-3.2.1.tgz#d0ac2ad55eb7b0bec926a5266f6c662aaa83dca5"
+
+isarray@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+
+isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+
+isexe@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+
+isobject@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
+ dependencies:
+ isarray "1.0.0"
+
+isobject@^3.0.0, isobject@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+
+isomorphic-fetch@^2.1.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
+ dependencies:
+ node-fetch "^1.0.1"
+ whatwg-fetch ">=0.10.0"
+
+isstream@^0.1.2, isstream@~0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
+
+jdu@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/jdu/-/jdu-1.0.0.tgz#28f1e388501785ae0a1d93e93ed0b14dd41e51ce"
+
+jquery@3.3.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
+
+jquery@>=1.6.4:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-2.2.4.tgz#2c89d6889b5eac522a7eea32c14521559c6cbf02"
+
+js-base64@^2.1.9:
+ version "2.4.9"
+ resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.9.tgz#748911fb04f48a60c4771b375cac45a80df11c03"
+
+js-tokens@^3.0.0, js-tokens@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+
+js-yaml@^3.12.0, js-yaml@^3.9.0:
+ version "3.12.0"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1"
+ dependencies:
+ argparse "^1.0.7"
+ esprima "^4.0.0"
+
+js-yaml@^3.2.6:
+ version "3.10.0"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc"
+ dependencies:
+ argparse "^1.0.7"
+ esprima "^4.0.0"
+
+js-yaml@~3.7.0:
+ version "3.7.0"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80"
+ dependencies:
+ argparse "^1.0.7"
+ esprima "^2.6.0"
+
+jsbn@~0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
+
+jsesc@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
+
+jsesc@^2.5.1:
+ version "2.5.1"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.1.tgz#e421a2a8e20d6b0819df28908f782526b96dd1fe"
+
+jsesc@~0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
+
+json-loader@^0.5.4:
+ version "0.5.7"
+ resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d"
+
+json-parse-better-errors@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+
+json-schema-traverse@^0.3.0:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
+
+json-schema-traverse@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+
+json-schema@0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
+
+json-stable-stringify-without-jsonify@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+
+json-stable-stringify@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af"
+ dependencies:
+ jsonify "~0.0.0"
+
+json-stringify-safe@~5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+
+json5@^0.5.0, json5@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
+
+jsonify@~0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
+
+jsprim@^1.2.2:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
+ dependencies:
+ assert-plus "1.0.0"
+ extsprintf "1.3.0"
+ json-schema "0.2.3"
+ verror "1.10.0"
+
+jsx-ast-utils@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz#e801b1b39985e20fffc87b40e3748080e2dcac7f"
+ dependencies:
+ array-includes "^3.0.3"
+
+kind-of@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-1.1.0.tgz#140a3d2d41a36d2efcfa9377b62c24f8495a5c44"
+
+kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
+ dependencies:
+ is-buffer "^1.1.5"
+
+kind-of@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
+ dependencies:
+ is-buffer "^1.1.5"
+
+kind-of@^5.0.0, kind-of@^5.0.2:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
+
+kind-of@^6.0.0, kind-of@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
+
+known-css-properties@^0.6.0:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.6.1.tgz#31b5123ad03d8d1a3f36bd4155459c981173478b"
+
+lazy-cache@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
+
+lcid@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
+ dependencies:
+ invert-kv "^1.0.0"
+
+levn@^0.3.0, levn@~0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
+ dependencies:
+ prelude-ls "~1.1.2"
+ type-check "~0.3.2"
+
+liftoff@^2.1.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.3.0.tgz#a98f2ff67183d8ba7cfaca10548bd7ff0550b385"
+ dependencies:
+ extend "^3.0.0"
+ findup-sync "^0.4.2"
+ fined "^1.0.1"
+ flagged-respawn "^0.3.2"
+ lodash.isplainobject "^4.0.4"
+ lodash.isstring "^4.0.1"
+ lodash.mapvalues "^4.4.0"
+ rechoir "^0.6.2"
+ resolve "^1.1.7"
+
+livereload-js@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.3.0.tgz#c3ab22e8aaf5bf3505d80d098cbad67726548c9a"
+
+load-json-file@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8"
+ dependencies:
+ graceful-fs "^4.1.2"
+ parse-json "^2.2.0"
+ pify "^2.0.0"
+ strip-bom "^3.0.0"
+
+load-json-file@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
+ dependencies:
+ graceful-fs "^4.1.2"
+ parse-json "^4.0.0"
+ pify "^3.0.0"
+ strip-bom "^3.0.0"
+
+loader-runner@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2"
+
+loader-utils@^1.0.2, loader-utils@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
+ dependencies:
+ big.js "^3.1.3"
+ emojis-list "^2.0.0"
+ json5 "^0.5.0"
+
+locate-path@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
+ dependencies:
+ p-locate "^2.0.0"
+ path-exists "^3.0.0"
+
+lodash-es@^4.17.5:
+ version "4.17.11"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0"
+
+lodash._basecopy@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36"
+
+lodash._basetostring@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz#d1861d877f824a52f669832dcaf3ee15566a07d5"
+
+lodash._basevalues@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz#5b775762802bde3d3297503e26300820fdf661b7"
+
+lodash._getnative@^3.0.0:
+ version "3.9.1"
+ resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
+
+lodash._isiterateecall@^3.0.0:
+ version "3.0.9"
+ resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
+
+lodash._reescape@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/lodash._reescape/-/lodash._reescape-3.0.0.tgz#2b1d6f5dfe07c8a355753e5f27fac7f1cde1616a"
+
+lodash._reevaluate@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz#58bc74c40664953ae0b124d806996daca431e2ed"
+
+lodash._reinterpolate@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
+
+lodash._root@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692"
+
+lodash.assign@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
+
+lodash.camelcase@4.3.0, lodash.camelcase@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+
+lodash.clone@^4.3.2:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6"
+
+lodash.curry@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170"
+
+lodash.defaults@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
+
+lodash.escape@^3.0.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-3.2.0.tgz#995ee0dc18c1b48cc92effae71a10aab5b487698"
+ dependencies:
+ lodash._root "^3.0.0"
+
+lodash.isarguments@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
+
+lodash.isarray@^3.0.0:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
+
+lodash.isplainobject@^4.0.4:
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+
+lodash.isstring@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
+
+lodash.kebabcase@4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
+
+lodash.keys@^3.0.0:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
+ dependencies:
+ lodash._getnative "^3.0.0"
+ lodash.isarguments "^3.0.0"
+ lodash.isarray "^3.0.0"
+
+lodash.mapvalues@^4.4.0:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
+
+lodash.memoize@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
+
+lodash.restparam@^3.0.0:
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
+
+lodash.snakecase@4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d"
+
+lodash.some@^4.2.2:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d"
+
+lodash.template@^3.0.0:
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f"
+ dependencies:
+ lodash._basecopy "^3.0.0"
+ lodash._basetostring "^3.0.0"
+ lodash._basevalues "^3.0.0"
+ lodash._isiterateecall "^3.0.0"
+ lodash._reinterpolate "^3.0.0"
+ lodash.escape "^3.0.0"
+ lodash.keys "^3.0.0"
+ lodash.restparam "^3.0.0"
+ lodash.templatesettings "^3.0.0"
+
+lodash.templatesettings@^3.0.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz#fb307844753b66b9f1afa54e262c745307dba8e5"
+ dependencies:
+ lodash._reinterpolate "^3.0.0"
+ lodash.escape "^3.0.0"
+
+lodash.uniq@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+
+lodash.upperfirst@4.3.1:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce"
+
+lodash@4.17.11, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5:
+ version "4.17.11"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
+
+lodash@^4.11.1, lodash@^4.14.0:
+ version "4.17.4"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
+
+lodash@~1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-1.0.2.tgz#8f57560c83b59fc270bd3d561b690043430e2551"
+
+log-symbols@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
+ dependencies:
+ chalk "^1.0.0"
+
+log-symbols@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
+ dependencies:
+ chalk "^2.0.1"
+
+longest-streak@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.2.tgz#2421b6ba939a443bb9ffebf596585a50b4c38e2e"
+
+longest@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
+
+loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+ dependencies:
+ js-tokens "^3.0.0 || ^4.0.0"
+
+loose-envify@^1.2.0, loose-envify@^1.3.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
+ dependencies:
+ js-tokens "^3.0.0"
+
+loud-rejection@^1.0.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
+ dependencies:
+ currently-unhandled "^0.4.1"
+ signal-exit "^3.0.0"
+
+lru-cache@2:
+ version "2.7.3"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952"
+
+lru-cache@^4.0.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55"
+ dependencies:
+ pseudomap "^1.0.2"
+ yallist "^2.1.2"
+
+lru-cache@^4.1.1:
+ version "4.1.3"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c"
+ dependencies:
+ pseudomap "^1.0.2"
+ yallist "^2.1.2"
+
+lru-queue@0.1:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3"
+ dependencies:
+ es5-ext "~0.10.2"
+
+macaddress@^0.2.8:
+ version "0.2.8"
+ resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12"
+
+make-dir@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978"
+ dependencies:
+ pify "^2.3.0"
+
+makeerror@1.0.x:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c"
+ dependencies:
+ tmpl "1.0.x"
+
+map-cache@^0.2.0, map-cache@^0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
+
+map-obj@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
+
+map-obj@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-2.0.0.tgz#a65cd29087a92598b8791257a523e021222ac1f9"
+
+map-stream@0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8"
+
+map-visit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
+ dependencies:
+ object-visit "^1.0.0"
+
+markdown-escapes@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.2.tgz#e639cbde7b99c841c0bacc8a07982873b46d2122"
+
+markdown-table@^1.1.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.2.tgz#c78db948fa879903a41bce522e3b96f801c63786"
+
+math-expression-evaluator@^1.2.14:
+ version "1.2.17"
+ resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
+
+math-random@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac"
+
+mathml-tag-names@^2.0.1:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.0.tgz#490b70e062ee24636536e3d9481e333733d00f2c"
+
+md5.js@^1.3.4:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d"
+ dependencies:
+ hash-base "^3.0.0"
+ inherits "^2.0.1"
+
+md5@2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
+ dependencies:
+ charenc "~0.0.1"
+ crypt "~0.0.1"
+ is-buffer "~1.1.1"
+
+mdast-util-compact@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.2.tgz#c12ebe16fffc84573d3e19767726de226e95f649"
+ dependencies:
+ unist-util-visit "^1.1.0"
+
+mem@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
+ dependencies:
+ mimic-fn "^1.0.0"
+
+memoizee@0.4.X:
+ version "0.4.11"
+ resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.11.tgz#bde9817663c9e40fdb2a4ea1c367296087ae8c8f"
+ dependencies:
+ d "1"
+ es5-ext "^0.10.30"
+ es6-weak-map "^2.0.2"
+ event-emitter "^0.3.5"
+ is-promise "^2.1"
+ lru-queue "0.1"
+ next-tick "1"
+ timers-ext "^0.1.2"
+
+memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
+ dependencies:
+ errno "^0.1.3"
+ readable-stream "^2.0.1"
+
+meow@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4"
+ dependencies:
+ camelcase-keys "^4.0.0"
+ decamelize-keys "^1.0.0"
+ loud-rejection "^1.0.0"
+ minimist-options "^3.0.1"
+ normalize-package-data "^2.3.4"
+ read-pkg-up "^3.0.0"
+ redent "^2.0.0"
+ trim-newlines "^2.0.0"
+ yargs-parser "^10.0.0"
+
+merge2@^1.2.1:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.2.tgz#03212e3da8d86c4d8523cebd6318193414f94e34"
+
+merge@^1.1.3:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da"
+
+micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7:
+ version "2.3.11"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
+ dependencies:
+ arr-diff "^2.0.0"
+ array-unique "^0.2.1"
+ braces "^1.8.2"
+ expand-brackets "^0.1.4"
+ extglob "^0.3.1"
+ filename-regex "^2.0.0"
+ is-extglob "^1.0.0"
+ is-glob "^2.0.1"
+ kind-of "^3.0.2"
+ normalize-path "^2.0.1"
+ object.omit "^2.0.0"
+ parse-glob "^3.0.4"
+ regex-cache "^0.4.2"
+
+micromatch@^3.1.10:
+ version "3.1.10"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ braces "^2.3.1"
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ extglob "^2.0.4"
+ fragment-cache "^0.2.1"
+ kind-of "^6.0.2"
+ nanomatch "^1.2.9"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.2"
+
+micromatch@^3.1.4:
+ version "3.1.5"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.5.tgz#d05e168c206472dfbca985bfef4f57797b4cd4ba"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ braces "^2.3.0"
+ define-property "^1.0.0"
+ extend-shallow "^2.0.1"
+ extglob "^2.0.2"
+ fragment-cache "^0.2.1"
+ kind-of "^6.0.0"
+ nanomatch "^1.2.5"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+miller-rabin@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.0.tgz#4a62fb1d42933c05583982f4c716f6fb9e6c6d3d"
+ dependencies:
+ bn.js "^4.0.0"
+ brorand "^1.0.1"
+
+mime-db@~1.30.0:
+ version "1.30.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
+
+mime-types@^2.1.12, mime-types@~2.1.17:
+ version "2.1.17"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
+ dependencies:
+ mime-db "~1.30.0"
+
+mime@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
+
+mimic-fn@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18"
+
+minimalistic-assert@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3"
+
+minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
+
+minimatch@^2.0.1:
+ version "2.0.10"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-2.0.10.tgz#8d087c39c6b38c001b97fca7ce6d0e1e80afbac7"
+ dependencies:
+ brace-expansion "^1.0.0"
+
+minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+ dependencies:
+ brace-expansion "^1.1.7"
+
+minimatch@~0.2.11:
+ version "0.2.14"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.2.14.tgz#c74e780574f63c6f9a090e90efbe6ef53a6a756a"
+ dependencies:
+ lru-cache "2"
+ sigmund "~1.0.0"
+
+minimist-options@^3.0.1:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-3.0.2.tgz#fba4c8191339e13ecf4d61beb03f070103f3d954"
+ dependencies:
+ arrify "^1.0.1"
+ is-plain-obj "^1.1.0"
+
+minimist@0.0.8:
+ version "0.0.8"
+ resolved "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+
+minimist@1.1.x:
+ version "1.1.3"
+ resolved "http://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8"
+
+minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
+
+mississippi@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
+ dependencies:
+ concat-stream "^1.5.0"
+ duplexify "^3.4.2"
+ end-of-stream "^1.1.0"
+ flush-write-stream "^1.0.0"
+ from2 "^2.1.0"
+ parallel-transform "^1.1.0"
+ pump "^2.0.1"
+ pumpify "^1.3.3"
+ stream-each "^1.1.0"
+ through2 "^2.0.0"
+
+mixin-deep@^1.2.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
+ dependencies:
+ for-in "^1.0.2"
+ is-extendable "^1.0.1"
+
+"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1:
+ version "0.5.1"
+ resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
+ dependencies:
+ minimist "0.0.8"
+
+mobile-detect@1.4.3:
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.3.tgz#e436a3839f5807dd4d3cd4e081f7d3a51ffda2dd"
+
+moment@2.22.2:
+ version "2.22.2"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
+
+mousetrap@1.6.2:
+ version "1.6.2"
+ resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.2.tgz#caadd9cf886db0986fb2fee59a82f6bd37527587"
+
+"mout@>=0.9 <2.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/mout/-/mout-1.0.0.tgz#9bdf1d4af57d66d47cb353a6335a3281098e1501"
+
+mout@^0.11.0:
+ version "0.11.1"
+ resolved "https://registry.yarnpkg.com/mout/-/mout-0.11.1.tgz#ba3611df5f0e5b1ffbfd01166b8f02d1f5fa2b99"
+
+move-concurrently@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
+ dependencies:
+ aproba "^1.1.1"
+ copy-concurrently "^1.0.0"
+ fs-write-stream-atomic "^1.0.8"
+ mkdirp "^0.5.1"
+ rimraf "^2.5.4"
+ run-queue "^1.0.3"
+
+ms@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+
+ms@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
+
+multipipe@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-0.1.2.tgz#2a8f2ddf70eed564dff2d57f1e1a137d9f05078b"
+ dependencies:
+ duplexer2 "0.0.2"
+
+mute-stream@0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
+
+nan@^2.0.5, nan@^2.3.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46"
+
+nanomatch@^1.2.5:
+ version "1.2.7"
+ resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.7.tgz#53cd4aa109ff68b7f869591fdc9d10daeeea3e79"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ define-property "^1.0.0"
+ extend-shallow "^2.0.1"
+ fragment-cache "^0.2.1"
+ is-odd "^1.0.0"
+ kind-of "^5.0.2"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+nanomatch@^1.2.9:
+ version "1.2.13"
+ resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ fragment-cache "^0.2.1"
+ is-windows "^1.0.2"
+ kind-of "^6.0.2"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+natives@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.0.tgz#e9ff841418a6b2ec7a495e939984f78f163e6e31"
+
+natural-compare@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
+
+new-from@0.0.3:
+ version "0.0.3"
+ resolved "https://registry.yarnpkg.com/new-from/-/new-from-0.0.3.tgz#1c4ad13613de3e15d6321b70ed5c23937ea25e67"
+ dependencies:
+ readable-stream "~1.1.8"
+
+next-tick@1:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
+
+nice-try@^1.0.4:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+
+node-fetch@^1.0.1:
+ version "1.7.3"
+ resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
+ dependencies:
+ encoding "^0.1.11"
+ is-stream "^1.0.1"
+
+node-int64@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
+
+node-libs-browser@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.0.0.tgz#a3a59ec97024985b46e958379646f96c4b616646"
+ dependencies:
+ assert "^1.1.1"
+ browserify-zlib "^0.1.4"
+ buffer "^4.3.0"
+ console-browserify "^1.1.0"
+ constants-browserify "^1.0.0"
+ crypto-browserify "^3.11.0"
+ domain-browser "^1.1.1"
+ events "^1.0.0"
+ https-browserify "0.0.1"
+ os-browserify "^0.2.0"
+ path-browserify "0.0.0"
+ process "^0.11.0"
+ punycode "^1.2.4"
+ querystring-es3 "^0.2.0"
+ readable-stream "^2.0.5"
+ stream-browserify "^2.0.1"
+ stream-http "^2.3.1"
+ string_decoder "^0.10.25"
+ timers-browserify "^2.0.2"
+ tty-browserify "0.0.0"
+ url "^0.11.0"
+ util "^0.10.3"
+ vm-browserify "0.0.4"
+
+node-pre-gyp@^0.6.36:
+ version "0.6.37"
+ resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.37.tgz#3c872b236b2e266e4140578fe1ee88f693323a05"
+ dependencies:
+ mkdirp "^0.5.1"
+ nopt "^4.0.1"
+ npmlog "^4.0.2"
+ rc "^1.1.7"
+ request "^2.81.0"
+ rimraf "^2.6.1"
+ semver "^5.3.0"
+ tape "^4.6.3"
+ tar "^2.2.1"
+ tar-pack "^3.4.0"
+
+node-releases@^1.0.0-alpha.11:
+ version "1.0.0-alpha.11"
+ resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.0.0-alpha.11.tgz#73c810acc2e5b741a17ddfbb39dfca9ab9359d8a"
+ dependencies:
+ semver "^5.3.0"
+
+node.extend@^1.1.2:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/node.extend/-/node.extend-1.1.6.tgz#a7b882c82d6c93a4863a5504bd5de8ec86258b96"
+ dependencies:
+ is "^3.1.0"
+
+nopt@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
+ dependencies:
+ abbrev "1"
+ osenv "^0.1.4"
+
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f"
+ dependencies:
+ hosted-git-info "^2.1.4"
+ is-builtin-module "^1.0.0"
+ semver "2 || 3 || 4 || 5"
+ validate-npm-package-license "^3.0.1"
+
+normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
+ dependencies:
+ remove-trailing-separator "^1.0.1"
+
+normalize-range@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
+
+normalize-selector@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03"
+
+normalize-url@^1.4.0:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c"
+ dependencies:
+ object-assign "^4.0.1"
+ prepend-http "^1.0.0"
+ query-string "^4.1.0"
+ sort-keys "^1.0.0"
+
+normalize.css@8.0.0:
+ version "8.0.0"
+ resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.0.tgz#14ac5e461612538a4ce9be90a7da23f86e718493"
+
+npm-path@^1.0.0, npm-path@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-1.1.0.tgz#0474ae00419c327d54701b7cf2cd05dc88be1140"
+ dependencies:
+ which "^1.2.4"
+
+npm-run-path@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
+ dependencies:
+ path-key "^2.0.0"
+
+npm-run@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/npm-run/-/npm-run-3.0.0.tgz#568920f840a98fd8e2299db66b2616e2476caf69"
+ dependencies:
+ minimist "^1.1.1"
+ npm-path "^1.0.1"
+ npm-which "^2.0.0"
+ serializerr "^1.0.1"
+ sync-exec "^0.6.2"
+
+npm-which@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-2.0.0.tgz#0c46982160b783093661d1d01bd4496d2feabbac"
+ dependencies:
+ commander "^2.2.0"
+ npm-path "^1.0.0"
+ which "^1.0.5"
+
+npmlog@^4.0.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+ dependencies:
+ are-we-there-yet "~1.1.2"
+ console-control-strings "~1.1.0"
+ gauge "~2.7.3"
+ set-blocking "~2.0.0"
+
+nsdeclare@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/nsdeclare/-/nsdeclare-0.1.0.tgz#10daa153642382d3cf2c01a916f4eb20a128b19f"
+
+num2fraction@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede"
+
+number-is-nan@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+
+oauth-sign@~0.8.2:
+ version "0.8.2"
+ resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
+
+object-assign@4.X, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+
+object-assign@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2"
+
+object-copy@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
+ dependencies:
+ copy-descriptor "^0.1.0"
+ define-property "^0.2.5"
+ kind-of "^3.0.3"
+
+object-inspect@~1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.3.0.tgz#5b1eb8e6742e2ee83342a637034d844928ba2f6d"
+
+object-keys@^1.0.8:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d"
+
+object-keys@~0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336"
+
+object-visit@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
+ dependencies:
+ isobject "^3.0.0"
+
+object.defaults@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf"
+ dependencies:
+ array-each "^1.0.1"
+ array-slice "^1.0.0"
+ for-own "^1.0.0"
+ isobject "^3.0.0"
+
+object.omit@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
+ dependencies:
+ for-own "^0.1.4"
+ is-extendable "^0.1.1"
+
+object.pick@^1.2.0, object.pick@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
+ dependencies:
+ isobject "^3.0.1"
+
+once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+ dependencies:
+ wrappy "1"
+
+once@~1.3.0:
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20"
+ dependencies:
+ wrappy "1"
+
+onetime@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
+ dependencies:
+ mimic-fn "^1.0.0"
+
+optionator@^0.8.2:
+ version "0.8.2"
+ resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
+ dependencies:
+ deep-is "~0.1.3"
+ fast-levenshtein "~2.0.4"
+ levn "~0.3.0"
+ prelude-ls "~1.1.2"
+ type-check "~0.3.2"
+ wordwrap "~1.0.0"
+
+orchestrator@^0.3.0:
+ version "0.3.8"
+ resolved "https://registry.yarnpkg.com/orchestrator/-/orchestrator-0.3.8.tgz#14e7e9e2764f7315fbac184e506c7aa6df94ad7e"
+ dependencies:
+ end-of-stream "~0.1.5"
+ sequencify "~0.0.7"
+ stream-consume "~0.1.0"
+
+ordered-read-streams@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz#fd565a9af8eb4473ba69b6ed8a34352cb552f126"
+
+os-browserify@^0.2.0:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f"
+
+os-homedir@^1.0.0, os-homedir@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+
+os-locale@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2"
+ dependencies:
+ execa "^0.7.0"
+ lcid "^1.0.0"
+ mem "^1.1.0"
+
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+
+osenv@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644"
+ dependencies:
+ os-homedir "^1.0.0"
+ os-tmpdir "^1.0.0"
+
+p-finally@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+
+p-limit@^1.1.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
+ dependencies:
+ p-try "^1.0.0"
+
+p-locate@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
+ dependencies:
+ p-limit "^1.1.0"
+
+p-map@^1.1.1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
+
+p-try@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
+
+pako@~0.2.0:
+ version "0.2.9"
+ resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
+
+parallel-transform@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06"
+ dependencies:
+ cyclist "~0.2.2"
+ inherits "^2.0.3"
+ readable-stream "^2.1.5"
+
+parse-asn1@^5.0.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.0.tgz#37c4f9b7ed3ab65c74817b5f2480937fbf97c712"
+ dependencies:
+ asn1.js "^4.0.0"
+ browserify-aes "^1.0.0"
+ create-hash "^1.1.0"
+ evp_bytestokey "^1.0.0"
+ pbkdf2 "^3.0.3"
+
+parse-entities@^1.0.2, parse-entities@^1.1.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.1.2.tgz#9eaf719b29dc3bd62246b4332009072e01527777"
+ dependencies:
+ character-entities "^1.0.0"
+ character-entities-legacy "^1.0.0"
+ character-reference-invalid "^1.0.0"
+ is-alphanumerical "^1.0.0"
+ is-decimal "^1.0.0"
+ is-hexadecimal "^1.0.0"
+
+parse-filepath@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.1.tgz#159d6155d43904d16c10ef698911da1e91969b73"
+ dependencies:
+ is-absolute "^0.2.3"
+ map-cache "^0.2.0"
+ path-root "^0.1.1"
+
+parse-glob@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
+ dependencies:
+ glob-base "^0.3.0"
+ is-dotfile "^1.0.0"
+ is-extglob "^1.0.0"
+ is-glob "^2.0.0"
+
+parse-json@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
+ dependencies:
+ error-ex "^1.2.0"
+
+parse-json@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
+ dependencies:
+ error-ex "^1.3.1"
+ json-parse-better-errors "^1.0.1"
+
+parse-passwd@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
+
+pascalcase@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
+
+path-browserify@0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a"
+
+path-dirname@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
+
+path-exists@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
+
+path-is-absolute@^1.0.0, path-is-absolute@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+
+path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
+
+path-key@^2.0.0, path-key@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+
+path-parse@^1.0.5:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
+
+path-root-regex@^0.1.0:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d"
+
+path-root@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7"
+ dependencies:
+ path-root-regex "^0.1.0"
+
+path-to-regexp@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d"
+ dependencies:
+ isarray "0.0.1"
+
+path-type@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
+ dependencies:
+ pify "^2.0.0"
+
+path-type@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
+ dependencies:
+ pify "^3.0.0"
+
+pause-stream@^0.0.11:
+ version "0.0.11"
+ resolved "http://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
+ dependencies:
+ through "~2.3"
+
+pbkdf2@^3.0.3:
+ version "3.0.14"
+ resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.14.tgz#a35e13c64799b06ce15320f459c230e68e73bade"
+ dependencies:
+ create-hash "^1.1.2"
+ create-hmac "^1.1.4"
+ ripemd160 "^2.0.1"
+ safe-buffer "^5.0.1"
+ sha.js "^2.4.8"
+
+performance-now@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+
+pify@^2.0.0, pify@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+
+pify@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
+
+pify@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.0.tgz#db04c982b632fd0df9090d14aaf1c8413cadb695"
+
+pinkie-promise@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
+ dependencies:
+ pinkie "^2.0.0"
+
+pinkie@^2.0.0:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
+
+pkg-dir@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
+ dependencies:
+ find-up "^2.1.0"
+
+plugin-error@1.0.1, plugin-error@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-1.0.1.tgz#77016bd8919d0ac377fdcdd0322328953ca5781c"
+ dependencies:
+ ansi-colors "^1.0.1"
+ arr-diff "^4.0.0"
+ arr-union "^3.1.0"
+ extend-shallow "^3.0.2"
+
+plugin-error@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-0.1.2.tgz#3b9bb3335ccf00f425e07437e19276967da47ace"
+ dependencies:
+ ansi-cyan "^0.1.1"
+ ansi-red "^0.1.1"
+ arr-diff "^1.0.1"
+ arr-union "^2.0.1"
+ extend-shallow "^1.1.2"
+
+pluralize@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
+
+posix-character-classes@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
+
+postcss-calc@^5.2.0:
+ version "5.3.1"
+ resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e"
+ dependencies:
+ postcss "^5.0.2"
+ postcss-message-helpers "^2.0.0"
+ reduce-css-calc "^1.2.6"
+
+postcss-colormin@^2.1.8:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-2.2.2.tgz#6631417d5f0e909a3d7ec26b24c8a8d1e4f96e4b"
+ dependencies:
+ colormin "^1.0.5"
+ postcss "^5.0.13"
+ postcss-value-parser "^3.2.3"
+
+postcss-convert-values@^2.3.4:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-2.6.1.tgz#bbd8593c5c1fd2e3d1c322bb925dcae8dae4d62d"
+ dependencies:
+ postcss "^5.0.11"
+ postcss-value-parser "^3.1.2"
+
+postcss-discard-comments@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz#befe89fafd5b3dace5ccce51b76b81514be00e3d"
+ dependencies:
+ postcss "^5.0.14"
+
+postcss-discard-duplicates@^2.0.1:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-2.1.0.tgz#b9abf27b88ac188158a5eb12abcae20263b91932"
+ dependencies:
+ postcss "^5.0.4"
+
+postcss-discard-empty@^2.0.1:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz#d2b4bd9d5ced5ebd8dcade7640c7d7cd7f4f92b5"
+ dependencies:
+ postcss "^5.0.14"
+
+postcss-discard-overridden@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz#8b1eaf554f686fb288cd874c55667b0aa3668d58"
+ dependencies:
+ postcss "^5.0.16"
+
+postcss-discard-unused@^2.2.1:
+ version "2.2.3"
+ resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz#bce30b2cc591ffc634322b5fb3464b6d934f4433"
+ dependencies:
+ postcss "^5.0.14"
+ uniqs "^2.0.0"
+
+postcss-filter-plugins@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/postcss-filter-plugins/-/postcss-filter-plugins-2.0.2.tgz#6d85862534d735ac420e4a85806e1f5d4286d84c"
+ dependencies:
+ postcss "^5.0.4"
+ uniqid "^4.0.0"
+
+postcss-html@^0.33.0:
+ version "0.33.0"
+ resolved "https://registry.yarnpkg.com/postcss-html/-/postcss-html-0.33.0.tgz#8ab6067d7a8a234e1937920b38760e3be1dca070"
+ dependencies:
+ htmlparser2 "^3.9.2"
+
+postcss-js@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-1.0.1.tgz#ffaf29226e399ea74b5dce02cab1729d7addbc7b"
+ dependencies:
+ camelcase-css "^1.0.1"
+ postcss "^6.0.11"
+
+postcss-jsx@^0.33.0:
+ version "0.33.0"
+ resolved "https://registry.yarnpkg.com/postcss-jsx/-/postcss-jsx-0.33.0.tgz#433f8aadd6f3b0ee403a62b441bca8db9c87471c"
+ dependencies:
+ "@babel/core" "^7.0.0-rc.1"
+ optionalDependencies:
+ postcss-styled ">=0.33.0"
+
+postcss-less@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-less/-/postcss-less-2.0.0.tgz#5d190b8e057ca446d60fe2e2587ad791c9029fb8"
+ dependencies:
+ postcss "^5.2.16"
+
+postcss-load-config@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.0.0.tgz#f1312ddbf5912cd747177083c5ef7a19d62ee484"
+ dependencies:
+ cosmiconfig "^4.0.0"
+ import-cwd "^2.0.0"
+
+postcss-loader@3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d"
+ dependencies:
+ loader-utils "^1.1.0"
+ postcss "^7.0.0"
+ postcss-load-config "^2.0.0"
+ schema-utils "^1.0.0"
+
+postcss-markdown@^0.33.0:
+ version "0.33.0"
+ resolved "https://registry.yarnpkg.com/postcss-markdown/-/postcss-markdown-0.33.0.tgz#2d0462742ee108c9d6020780184b499630b8b33a"
+ dependencies:
+ remark "^9.0.0"
+ unist-util-find-all-after "^1.0.2"
+
+postcss-media-query-parser@^0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244"
+
+postcss-merge-idents@^2.1.5:
+ version "2.1.7"
+ resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz#4c5530313c08e1d5b3bbf3d2bbc747e278eea270"
+ dependencies:
+ has "^1.0.1"
+ postcss "^5.0.10"
+ postcss-value-parser "^3.1.1"
+
+postcss-merge-longhand@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-2.0.2.tgz#23d90cd127b0a77994915332739034a1a4f3d658"
+ dependencies:
+ postcss "^5.0.4"
+
+postcss-merge-rules@^2.0.3:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-2.1.2.tgz#d1df5dfaa7b1acc3be553f0e9e10e87c61b5f721"
+ dependencies:
+ browserslist "^1.5.2"
+ caniuse-api "^1.5.2"
+ postcss "^5.0.4"
+ postcss-selector-parser "^2.2.2"
+ vendors "^1.0.0"
+
+postcss-message-helpers@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e"
+
+postcss-minify-font-values@^1.0.2:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz#4b58edb56641eba7c8474ab3526cafd7bbdecb69"
+ dependencies:
+ object-assign "^4.0.1"
+ postcss "^5.0.4"
+ postcss-value-parser "^3.0.2"
+
+postcss-minify-gradients@^1.0.1:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz#5dbda11373703f83cfb4a3ea3881d8d75ff5e6e1"
+ dependencies:
+ postcss "^5.0.12"
+ postcss-value-parser "^3.3.0"
+
+postcss-minify-params@^1.0.4:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-1.2.2.tgz#ad2ce071373b943b3d930a3fa59a358c28d6f1f3"
+ dependencies:
+ alphanum-sort "^1.0.1"
+ postcss "^5.0.2"
+ postcss-value-parser "^3.0.2"
+ uniqs "^2.0.0"
+
+postcss-minify-selectors@^2.0.4:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-2.1.1.tgz#b2c6a98c0072cf91b932d1a496508114311735bf"
+ dependencies:
+ alphanum-sort "^1.0.2"
+ has "^1.0.1"
+ postcss "^5.0.14"
+ postcss-selector-parser "^2.0.0"
+
+postcss-mixins@6.2.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-mixins/-/postcss-mixins-6.2.0.tgz#fa9d2c2166b2ae7745956c727ab9dd2de4b96a40"
+ dependencies:
+ globby "^6.1.0"
+ postcss "^6.0.13"
+ postcss-js "^1.0.1"
+ postcss-simple-vars "^4.1.0"
+ sugarss "^1.0.0"
+
+postcss-modules-extract-imports@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85"
+ dependencies:
+ postcss "^6.0.1"
+
+postcss-modules-local-by-default@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069"
+ dependencies:
+ css-selector-tokenizer "^0.7.0"
+ postcss "^6.0.1"
+
+postcss-modules-scope@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90"
+ dependencies:
+ css-selector-tokenizer "^0.7.0"
+ postcss "^6.0.1"
+
+postcss-modules-values@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20"
+ dependencies:
+ icss-replace-symbols "^1.1.0"
+ postcss "^6.0.1"
+
+postcss-nested@4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-4.1.0.tgz#271da8a047f2ee378139410ae2400b1c67d0bf30"
+ dependencies:
+ postcss "^7.0.2"
+ postcss-selector-parser "^3.1.1"
+
+postcss-normalize-charset@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz#ef9ee71212d7fe759c78ed162f61ed62b5cb93f1"
+ dependencies:
+ postcss "^5.0.5"
+
+postcss-normalize-url@^3.0.7:
+ version "3.0.8"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-3.0.8.tgz#108f74b3f2fcdaf891a2ffa3ea4592279fc78222"
+ dependencies:
+ is-absolute-url "^2.0.0"
+ normalize-url "^1.4.0"
+ postcss "^5.0.14"
+ postcss-value-parser "^3.2.3"
+
+postcss-ordered-values@^2.1.0:
+ version "2.2.3"
+ resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz#eec6c2a67b6c412a8db2042e77fe8da43f95c11d"
+ dependencies:
+ postcss "^5.0.4"
+ postcss-value-parser "^3.0.1"
+
+postcss-reduce-idents@^2.2.2:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-2.4.0.tgz#c2c6d20cc958284f6abfbe63f7609bf409059ad3"
+ dependencies:
+ postcss "^5.0.4"
+ postcss-value-parser "^3.0.2"
+
+postcss-reduce-initial@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-1.0.1.tgz#68f80695f045d08263a879ad240df8dd64f644ea"
+ dependencies:
+ postcss "^5.0.4"
+
+postcss-reduce-transforms@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz#ff76f4d8212437b31c298a42d2e1444025771ae1"
+ dependencies:
+ has "^1.0.1"
+ postcss "^5.0.8"
+ postcss-value-parser "^3.0.1"
+
+postcss-reporter@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-reporter/-/postcss-reporter-5.0.0.tgz#a14177fd1342829d291653f2786efd67110332c3"
+ dependencies:
+ chalk "^2.0.1"
+ lodash "^4.17.4"
+ log-symbols "^2.0.0"
+ postcss "^6.0.8"
+
+postcss-resolve-nested-selector@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz#29ccbc7c37dedfac304e9fff0bf1596b3f6a0e4e"
+
+postcss-safe-parser@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-4.0.1.tgz#8756d9e4c36fdce2c72b091bbc8ca176ab1fcdea"
+ dependencies:
+ postcss "^7.0.0"
+
+postcss-sass@^0.3.0:
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/postcss-sass/-/postcss-sass-0.3.3.tgz#bec188ac285d21ac8feba194c2f327fdda31e671"
+ dependencies:
+ gonzales-pe "^4.2.3"
+ postcss "^7.0.1"
+
+postcss-scss@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-2.0.0.tgz#248b0a28af77ea7b32b1011aba0f738bda27dea1"
+ dependencies:
+ postcss "^7.0.0"
+
+postcss-selector-parser@^2.0.0, postcss-selector-parser@^2.2.2:
+ version "2.2.3"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz#f9437788606c3c9acee16ffe8d8b16297f27bb90"
+ dependencies:
+ flatten "^1.0.2"
+ indexes-of "^1.0.1"
+ uniq "^1.0.1"
+
+postcss-selector-parser@^3.1.0, postcss-selector-parser@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865"
+ dependencies:
+ dot-prop "^4.1.1"
+ indexes-of "^1.0.1"
+ uniq "^1.0.1"
+
+postcss-simple-vars@5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-5.0.1.tgz#850971fdfedf236ea1c815569ce261dab8623aa2"
+ dependencies:
+ postcss "^7.0.2"
+
+postcss-simple-vars@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-4.1.0.tgz#043248cfef8d3f51b3486a28c09f8375dbf1b2f9"
+ dependencies:
+ postcss "^6.0.9"
+
+postcss-sorting@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-sorting/-/postcss-sorting-4.0.0.tgz#abfdf41ff8f7710f66f5dc7e78a3a3cce3983c21"
+ dependencies:
+ lodash "^4.17.4"
+ postcss "^7.0.0"
+
+postcss-styled@>=0.33.0, postcss-styled@^0.33.0:
+ version "0.33.0"
+ resolved "https://registry.yarnpkg.com/postcss-styled/-/postcss-styled-0.33.0.tgz#69be377584105a582fda7e4f76888e5b97eed737"
+
+postcss-svgo@^2.1.1:
+ version "2.1.6"
+ resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-2.1.6.tgz#b6df18aa613b666e133f08adb5219c2684ac108d"
+ dependencies:
+ is-svg "^2.0.0"
+ postcss "^5.0.14"
+ postcss-value-parser "^3.2.3"
+ svgo "^0.7.0"
+
+postcss-syntax@^0.33.0:
+ version "0.33.0"
+ resolved "https://registry.yarnpkg.com/postcss-syntax/-/postcss-syntax-0.33.0.tgz#59c0c678d2f9ecefa84c6ce9ef46fc805c54ab3a"
+
+postcss-unique-selectors@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz#981d57d29ddcb33e7b1dfe1fd43b8649f933ca1d"
+ dependencies:
+ alphanum-sort "^1.0.1"
+ postcss "^5.0.4"
+ uniqs "^2.0.0"
+
+postcss-value-parser@^3.0.1, postcss-value-parser@^3.0.2, postcss-value-parser@^3.1.1, postcss-value-parser@^3.1.2, postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15"
+
+postcss-zindex@^2.0.1:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-2.2.0.tgz#d2109ddc055b91af67fc4cb3b025946639d2af22"
+ dependencies:
+ has "^1.0.1"
+ postcss "^5.0.4"
+ uniqs "^2.0.0"
+
+postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.19, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8:
+ version "5.2.17"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.17.tgz#cf4f597b864d65c8a492b2eabe9d706c879c388b"
+ dependencies:
+ chalk "^1.1.3"
+ js-base64 "^2.1.9"
+ source-map "^0.5.6"
+ supports-color "^3.2.3"
+
+postcss@^5.2.16:
+ version "5.2.18"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.18.tgz#badfa1497d46244f6390f58b319830d9107853c5"
+ dependencies:
+ chalk "^1.1.3"
+ js-base64 "^2.1.9"
+ source-map "^0.5.6"
+ supports-color "^3.2.3"
+
+postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.11:
+ version "6.0.11"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.11.tgz#f48db210b1d37a7f7ab6499b7a54982997ab6f72"
+ dependencies:
+ chalk "^2.1.0"
+ source-map "^0.5.7"
+ supports-color "^4.4.0"
+
+postcss@^6.0.13, postcss@^6.0.8:
+ version "6.0.23"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
+ dependencies:
+ chalk "^2.4.1"
+ source-map "^0.6.1"
+ supports-color "^5.4.0"
+
+postcss@^6.0.9:
+ version "6.0.12"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.12.tgz#6b0155089d2d212f7bd6a0cecd4c58c007403535"
+ dependencies:
+ chalk "^2.1.0"
+ source-map "^0.5.7"
+ supports-color "^4.4.0"
+
+postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.2:
+ version "7.0.2"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.2.tgz#7b5a109de356804e27f95a960bef0e4d5bc9bb18"
+ dependencies:
+ chalk "^2.4.1"
+ source-map "^0.6.1"
+ supports-color "^5.4.0"
+
+prefix-style@2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06"
+
+prelude-ls@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
+
+prepend-http@^1.0.0:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
+
+preserve@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+
+pretty-hrtime@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
+
+private@^0.1.6, private@^0.1.7:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1"
+
+private@^0.1.8:
+ version "0.1.8"
+ resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
+
+process-nextick-args@^1.0.6, process-nextick-args@~1.0.6:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3"
+
+process-nextick-args@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
+
+process@^0.11.0:
+ version "0.11.10"
+ resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
+
+progress@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
+
+promise-inflight@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
+
+promise@^7.1.1:
+ version "7.3.1"
+ resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
+ dependencies:
+ asap "~2.0.3"
+
+prop-types@15.6.2, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2:
+ version "15.6.2"
+ resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
+ dependencies:
+ loose-envify "^1.3.1"
+ object-assign "^4.1.1"
+
+prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8:
+ version "15.5.10"
+ resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
+ dependencies:
+ fbjs "^0.8.9"
+ loose-envify "^1.3.1"
+
+prop-types@^15.5.7:
+ version "15.6.0"
+ resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
+ dependencies:
+ fbjs "^0.8.16"
+ loose-envify "^1.3.1"
+ object-assign "^4.1.1"
+
+protochain@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/protochain/-/protochain-1.0.5.tgz#991c407e99de264aadf8f81504b5e7faf7bfa260"
+
+prr@~0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a"
+
+prr@~1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
+
+pseudomap@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+
+public-encrypt@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6"
+ dependencies:
+ bn.js "^4.1.0"
+ browserify-rsa "^4.0.0"
+ create-hash "^1.1.0"
+ parse-asn1 "^5.0.0"
+ randombytes "^2.0.1"
+
+pump@^2.0.0, pump@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
+ dependencies:
+ end-of-stream "^1.1.0"
+ once "^1.3.1"
+
+pumpify@^1.3.3:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
+ dependencies:
+ duplexify "^3.6.0"
+ inherits "^2.0.3"
+ pump "^2.0.0"
+
+punycode@1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
+
+punycode@^1.2.4, punycode@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
+
+punycode@^2.1.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+
+q@^1.1.2:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
+
+qs@6.5.2, qs@^6.4.0:
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+
+qs@~6.5.1:
+ version "6.5.1"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
+
+query-string@^4.1.0:
+ version "4.2.2"
+ resolved "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz#888a6fcb6f76070ba39f2f3025c87099defa1645"
+ dependencies:
+ object-assign "^4.1.0"
+ strict-uri-encode "^1.0.0"
+
+querystring-es3@^0.2.0:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
+
+querystring@0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
+
+quick-lru@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8"
+
+raf@^3.1.0:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/raf/-/raf-3.3.2.tgz#0c13be0b5b49b46f76d6669248d527cf2b02fe27"
+ dependencies:
+ performance-now "^2.1.0"
+
+randomatic@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.0.tgz#36f2ca708e9e567f5ed2ec01949026d50aa10116"
+ dependencies:
+ is-number "^4.0.0"
+ kind-of "^6.0.0"
+ math-random "^1.0.1"
+
+randombytes@^2.0.0, randombytes@^2.0.1:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.5.tgz#dc009a246b8d09a177b4b7a0ae77bc570f4b1b79"
+ dependencies:
+ safe-buffer "^5.1.0"
+
+raw-body@~1.1.0:
+ version "1.1.7"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425"
+ dependencies:
+ bytes "1"
+ string_decoder "0.10"
+
+rc@^1.1.7:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.1.tgz#2e03e8e42ee450b8cb3dce65be1bf8974e1dfd95"
+ dependencies:
+ deep-extend "~0.4.0"
+ ini "~1.3.0"
+ minimist "^1.2.0"
+ strip-json-comments "~2.0.1"
+
+react-addons-shallow-compare@15.6.2:
+ version "15.6.2"
+ resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f"
+ dependencies:
+ fbjs "^0.8.4"
+ object-assign "^4.1.0"
+
+react-async-script@1.0.0, react-async-script@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.0.0.tgz#3578153247bc3f9654a5878c4142539ffbf65c2d"
+ dependencies:
+ hoist-non-react-statics "^3.0.1"
+ prop-types "^15.5.0"
+
+react-autosuggest@9.4.1:
+ version "9.4.1"
+ resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.1.tgz#fe636b196eaffaf1d29283c2fc55f8a93cf3666d"
+ dependencies:
+ prop-types "^15.5.10"
+ react-autowhatever "^10.1.2"
+ shallow-equal "^1.0.0"
+
+react-autowhatever@^10.1.2:
+ version "10.1.2"
+ resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.1.2.tgz#200ffc41373b2189e3f6140ac7bdb82363a79fd3"
+ dependencies:
+ prop-types "^15.5.8"
+ react-themeable "^1.1.0"
+ section-iterator "^2.0.0"
+
+react-custom-scrollbars@4.2.1:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/react-custom-scrollbars/-/react-custom-scrollbars-4.2.1.tgz#830fd9502927e97e8a78c2086813899b2a8b66db"
+ dependencies:
+ dom-css "^2.0.0"
+ prop-types "^15.5.10"
+ raf "^3.1.0"
+
+react-dnd-html5-backend@5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-5.0.1.tgz#0b578d79c5c01317c70414c8d717f632b919d4f1"
+ dependencies:
+ autobind-decorator "^2.1.0"
+ dnd-core "^4.0.5"
+ lodash "^4.17.10"
+ shallowequal "^1.0.2"
+
+react-dnd@5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-5.0.0.tgz#c4a17c70109e456dad8906be838e6ee8f32b06b5"
+ dependencies:
+ dnd-core "^4.0.5"
+ hoist-non-react-statics "^2.5.0"
+ invariant "^2.1.0"
+ lodash "^4.17.10"
+ recompose "^0.27.1"
+ shallowequal "^1.0.2"
+
+react-document-title@2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/react-document-title/-/react-document-title-2.0.3.tgz#bbf922a0d71412fc948245e4283b2412df70f2b9"
+ dependencies:
+ prop-types "^15.5.6"
+ react-side-effect "^1.0.2"
+
+react-dom@16.5.2:
+ version "16.5.2"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7"
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+ prop-types "^15.6.2"
+ schedule "^0.5.0"
+
+react-google-recaptcha@1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-1.0.2.tgz#d77d0c91558d07c2f9f22b8ec05c383cbdbcabac"
+ dependencies:
+ prop-types "^15.5.0"
+ react-async-script "^1.0.0"
+
+react-is@^16.3.2:
+ version "16.5.1"
+ resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.1.tgz#c6e8734fd548a22e1cef4fd0833afbeb433b85ee"
+
+react-lazyload@2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/react-lazyload/-/react-lazyload-2.3.0.tgz#ccb134223012447074a96543954f44b055dc6185"
+
+react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
+
+react-measure@1.4.7:
+ version "1.4.7"
+ resolved "https://registry.yarnpkg.com/react-measure/-/react-measure-1.4.7.tgz#a1d2ca0dcfef04978b7ac263a765dcb6a0936fdb"
+ dependencies:
+ get-node-dimensions "^1.2.0"
+ prop-types "^15.5.4"
+ resize-observer-polyfill "^1.4.1"
+
+react-redux@5.0.7:
+ version "5.0.7"
+ resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.7.tgz#0dc1076d9afb4670f993ffaef44b8f8c1155a4c8"
+ dependencies:
+ hoist-non-react-statics "^2.5.0"
+ invariant "^2.0.0"
+ lodash "^4.17.5"
+ lodash-es "^4.17.5"
+ loose-envify "^1.1.0"
+ prop-types "^15.6.0"
+
+react-router-dom@4.3.1:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6"
+ dependencies:
+ history "^4.7.2"
+ invariant "^2.2.4"
+ loose-envify "^1.3.1"
+ prop-types "^15.6.1"
+ react-router "^4.3.1"
+ warning "^4.0.1"
+
+react-router-redux@5.0.0-alpha.6:
+ version "5.0.0-alpha.6"
+ resolved "https://registry.yarnpkg.com/react-router-redux/-/react-router-redux-5.0.0-alpha.6.tgz#7418663c2ecd3c51be856fcf28f3d1deecc1a576"
+ dependencies:
+ history "^4.5.1"
+ prop-types "^15.5.4"
+ react-router "^4.1.1"
+
+react-router@^4.1.1:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.2.0.tgz#61f7b3e3770daeb24062dae3eedef1b054155986"
+ dependencies:
+ history "^4.7.2"
+ hoist-non-react-statics "^2.3.0"
+ invariant "^2.2.2"
+ loose-envify "^1.3.1"
+ path-to-regexp "^1.7.0"
+ prop-types "^15.5.4"
+ warning "^3.0.0"
+
+react-router@^4.3.1:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.3.1.tgz#aada4aef14c809cb2e686b05cee4742234506c4e"
+ dependencies:
+ history "^4.7.2"
+ hoist-non-react-statics "^2.5.0"
+ invariant "^2.2.4"
+ loose-envify "^1.3.1"
+ path-to-regexp "^1.7.0"
+ prop-types "^15.6.1"
+ warning "^4.0.1"
+
+react-side-effect@^1.0.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.1.3.tgz#512c25abe0dec172834c4001ec5c51e04d41bc5c"
+ dependencies:
+ exenv "^1.2.1"
+ shallowequal "^1.0.1"
+
+react-slider@0.11.2:
+ version "0.11.2"
+ resolved "https://registry.yarnpkg.com/react-slider/-/react-slider-0.11.2.tgz#ae014e1454c3cdd5f28b5c2495b2a08abd9971e6"
+
+react-tabs@2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-2.3.0.tgz#0c37e786f288d369824acd06a96bd1818ab8b0dc"
+ dependencies:
+ classnames "^2.2.0"
+ prop-types "^15.5.0"
+
+react-tether@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/react-tether/-/react-tether-1.0.1.tgz#6e5173764d4f9b8bef6d1b20ff51972909674942"
+ dependencies:
+ prop-types "^15.5.8"
+ tether "^1.4.3"
+
+react-text-truncate@0.13.1:
+ version "0.13.1"
+ resolved "https://registry.yarnpkg.com/react-text-truncate/-/react-text-truncate-0.13.1.tgz#0632cbf8bdd5f7826865c7cc55dc8a2c3a2c9147"
+ dependencies:
+ prop-types "^15.5.7"
+
+react-themeable@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e"
+ dependencies:
+ object-assign "^3.0.0"
+
+react-virtualized@9.20.1:
+ version "9.20.1"
+ resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.20.1.tgz#02dc08fe9070386b8c48e2ac56bce7af0208d22d"
+ dependencies:
+ babel-runtime "^6.26.0"
+ classnames "^2.2.3"
+ dom-helpers "^2.4.0 || ^3.0.0"
+ loose-envify "^1.3.0"
+ prop-types "^15.6.0"
+ react-lifecycles-compat "^3.0.4"
+
+react@16.5.2:
+ version "16.5.2"
+ resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42"
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+ prop-types "^15.6.2"
+ schedule "^0.5.0"
+
+read-pkg-up@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be"
+ dependencies:
+ find-up "^2.0.0"
+ read-pkg "^2.0.0"
+
+read-pkg-up@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07"
+ dependencies:
+ find-up "^2.0.0"
+ read-pkg "^3.0.0"
+
+read-pkg@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8"
+ dependencies:
+ load-json-file "^2.0.0"
+ normalize-package-data "^2.3.2"
+ path-type "^2.0.0"
+
+read-pkg@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
+ dependencies:
+ load-json-file "^4.0.0"
+ normalize-package-data "^2.3.2"
+ path-type "^3.0.0"
+
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.4:
+ version "2.3.6"
+ resolved "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.3"
+ isarray "~1.0.0"
+ process-nextick-args "~2.0.0"
+ safe-buffer "~5.1.1"
+ string_decoder "~1.1.1"
+ util-deprecate "~1.0.1"
+
+"readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.17:
+ version "1.0.34"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.1"
+ isarray "0.0.1"
+ string_decoder "~0.10.x"
+
+readable-stream@^1.0.33, readable-stream@~1.1.8, readable-stream@~1.1.9:
+ version "1.1.14"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.1"
+ isarray "0.0.1"
+ string_decoder "~0.10.x"
+
+readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.3"
+ isarray "~1.0.0"
+ process-nextick-args "~1.0.6"
+ safe-buffer "~5.1.1"
+ string_decoder "~1.0.3"
+ util-deprecate "~1.0.1"
+
+readdirp@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78"
+ dependencies:
+ graceful-fs "^4.1.2"
+ minimatch "^3.0.2"
+ readable-stream "^2.0.2"
+ set-immediate-shim "^1.0.1"
+
+rechoir@^0.6.2:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
+ dependencies:
+ resolve "^1.1.6"
+
+recompose@^0.27.1:
+ version "0.27.1"
+ resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.27.1.tgz#1a49e931f183634516633bbb4f4edbfd3f38a7ba"
+ dependencies:
+ babel-runtime "^6.26.0"
+ change-emitter "^0.1.2"
+ fbjs "^0.8.1"
+ hoist-non-react-statics "^2.3.1"
+ react-lifecycles-compat "^3.0.2"
+ symbol-observable "^1.0.4"
+
+redent@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa"
+ dependencies:
+ indent-string "^3.0.0"
+ strip-indent "^2.0.0"
+
+reduce-css-calc@^1.2.6:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716"
+ dependencies:
+ balanced-match "^0.4.2"
+ math-expression-evaluator "^1.2.14"
+ reduce-function-call "^1.0.1"
+
+reduce-function-call@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99"
+ dependencies:
+ balanced-match "^0.4.2"
+
+reduce-reducers@^0.1.0:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.2.tgz#fa1b4718bc5292a71ddd1e5d839c9bea9770f14b"
+
+redux-actions@2.6.1:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.6.1.tgz#42c06e94739fbe6db35db3605abb105bdb3724d8"
+ dependencies:
+ invariant "^2.2.1"
+ lodash.camelcase "^4.3.0"
+ lodash.curry "^4.1.1"
+ reduce-reducers "^0.1.0"
+
+redux-batched-actions@0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/redux-batched-actions/-/redux-batched-actions-0.4.0.tgz#ad7ae145bffc0ff4eca2509314731ab358910429"
+
+redux-localstorage@0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/redux-localstorage/-/redux-localstorage-0.4.1.tgz#faf6d719c581397294d811473ffcedee065c933c"
+
+redux-thunk@2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"
+
+redux@4.0.0, redux@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03"
+ dependencies:
+ loose-envify "^1.1.0"
+ symbol-observable "^1.2.0"
+
+regenerate@^1.2.1:
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.3.tgz#0c336d3980553d755c39b586ae3b20aa49c82b7f"
+
+regenerator-runtime@^0.11.0:
+ version "0.11.0"
+ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz#7e54fe5b5ccd5d6624ea6255c3473be090b802e1"
+
+regenerator-transform@^0.10.0:
+ version "0.10.1"
+ resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd"
+ dependencies:
+ babel-runtime "^6.18.0"
+ babel-types "^6.19.0"
+ private "^0.1.6"
+
+regex-cache@^0.4.2:
+ version "0.4.4"
+ resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
+ dependencies:
+ is-equal-shallow "^0.1.3"
+
+regex-not@^1.0.0, regex-not@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
+ dependencies:
+ extend-shallow "^3.0.2"
+ safe-regex "^1.1.0"
+
+regexpp@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.0.tgz#b2a7534a85ca1b033bcf5ce9ff8e56d4e0755365"
+
+regexpu-core@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b"
+ dependencies:
+ regenerate "^1.2.1"
+ regjsgen "^0.2.0"
+ regjsparser "^0.1.4"
+
+regexpu-core@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240"
+ dependencies:
+ regenerate "^1.2.1"
+ regjsgen "^0.2.0"
+ regjsparser "^0.1.4"
+
+regjsgen@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7"
+
+regjsparser@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c"
+ dependencies:
+ jsesc "~0.5.0"
+
+remark-parse@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95"
+ dependencies:
+ collapse-white-space "^1.0.2"
+ is-alphabetical "^1.0.0"
+ is-decimal "^1.0.0"
+ is-whitespace-character "^1.0.0"
+ is-word-character "^1.0.0"
+ markdown-escapes "^1.0.0"
+ parse-entities "^1.1.0"
+ repeat-string "^1.5.4"
+ state-toggle "^1.0.0"
+ trim "0.0.1"
+ trim-trailing-lines "^1.0.0"
+ unherit "^1.0.4"
+ unist-util-remove-position "^1.0.0"
+ vfile-location "^2.0.0"
+ xtend "^4.0.1"
+
+remark-stringify@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-5.0.0.tgz#336d3a4d4a6a3390d933eeba62e8de4bd280afba"
+ dependencies:
+ ccount "^1.0.0"
+ is-alphanumeric "^1.0.0"
+ is-decimal "^1.0.0"
+ is-whitespace-character "^1.0.0"
+ longest-streak "^2.0.1"
+ markdown-escapes "^1.0.0"
+ markdown-table "^1.1.0"
+ mdast-util-compact "^1.0.0"
+ parse-entities "^1.0.2"
+ repeat-string "^1.5.4"
+ state-toggle "^1.0.0"
+ stringify-entities "^1.0.1"
+ unherit "^1.0.4"
+ xtend "^4.0.1"
+
+remark@^9.0.0:
+ version "9.0.0"
+ resolved "https://registry.yarnpkg.com/remark/-/remark-9.0.0.tgz#c5cfa8ec535c73a67c4b0f12bfdbd3a67d8b2f60"
+ dependencies:
+ remark-parse "^5.0.0"
+ remark-stringify "^5.0.0"
+ unified "^6.0.0"
+
+remove-trailing-separator@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
+
+repeat-element@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
+
+repeat-string@^1.5.0, repeat-string@^1.5.2, repeat-string@^1.5.4, repeat-string@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
+
+repeating@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
+ dependencies:
+ is-finite "^1.0.0"
+
+replace-ext@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
+
+replace-ext@1.0.0, replace-ext@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
+
+request@^2.81.0:
+ version "2.82.0"
+ resolved "https://registry.yarnpkg.com/request/-/request-2.82.0.tgz#2ba8a92cd7ac45660ea2b10a53ae67cd247516ea"
+ dependencies:
+ aws-sign2 "~0.7.0"
+ aws4 "^1.6.0"
+ caseless "~0.12.0"
+ combined-stream "~1.0.5"
+ extend "~3.0.1"
+ forever-agent "~0.6.1"
+ form-data "~2.3.1"
+ har-validator "~5.0.3"
+ hawk "~6.0.2"
+ http-signature "~1.2.0"
+ is-typedarray "~1.0.0"
+ isstream "~0.1.2"
+ json-stringify-safe "~5.0.1"
+ mime-types "~2.1.17"
+ oauth-sign "~0.8.2"
+ performance-now "^2.1.0"
+ qs "~6.5.1"
+ safe-buffer "^5.1.1"
+ stringstream "~0.0.5"
+ tough-cookie "~2.3.2"
+ tunnel-agent "^0.6.0"
+ uuid "^3.1.0"
+
+require-directory@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+
+require-from-string@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.1.tgz#c545233e9d7da6616e9d59adfb39fc9f588676ff"
+
+require-main-filename@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"
+
+require-nocache@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/require-nocache/-/require-nocache-1.0.0.tgz#a665d0b60a07e8249875790a4d350219d3c85fa3"
+
+require-uncached@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3"
+ dependencies:
+ caller-path "^0.1.0"
+ resolve-from "^1.0.0"
+
+reselect@3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/reselect/-/reselect-3.0.1.tgz#efdaa98ea7451324d092b2b2163a6a1d7a9a2147"
+
+resize-observer-polyfill@^1.4.1:
+ version "1.4.2"
+ resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.4.2.tgz#a37198e6209e888acb1532a9968e06d38b6788e5"
+
+resolve-dir@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e"
+ dependencies:
+ expand-tilde "^1.2.2"
+ global-modules "^0.2.3"
+
+resolve-from@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226"
+
+resolve-from@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"
+
+resolve-from@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
+
+resolve-pathname@^2.0.0, resolve-pathname@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879"
+
+resolve-url@^0.2.1, resolve-url@~0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
+
+resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@~1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86"
+ dependencies:
+ path-parse "^1.0.5"
+
+resolve@^1.3.2:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26"
+ dependencies:
+ path-parse "^1.0.5"
+
+restore-cursor@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
+ dependencies:
+ onetime "^2.0.0"
+ signal-exit "^3.0.2"
+
+resumer@~0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/resumer/-/resumer-0.0.0.tgz#f1e8f461e4064ba39e82af3cdc2a8c893d076759"
+ dependencies:
+ through "~2.3.4"
+
+ret@~0.1.10:
+ version "0.1.15"
+ resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+
+right-align@^0.1.1:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"
+ dependencies:
+ align-text "^0.1.1"
+
+rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2:
+ version "2.6.2"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36"
+ dependencies:
+ glob "^7.0.5"
+
+ripemd160@^2.0.0, ripemd160@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7"
+ dependencies:
+ hash-base "^2.0.0"
+ inherits "^2.0.1"
+
+rocambole-indent@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/rocambole-indent/-/rocambole-indent-2.0.4.tgz#a18a24977ca0400b861daa4631e861dcb52d085c"
+ dependencies:
+ debug "^2.1.3"
+ mout "^0.11.0"
+ rocambole-token "^1.2.1"
+
+rocambole-linebreak@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/rocambole-linebreak/-/rocambole-linebreak-1.0.2.tgz#03621515b43b4721c97e5a1c1bca5a0366368f2f"
+ dependencies:
+ debug "^2.1.3"
+ rocambole-token "^1.2.1"
+ semver "^4.3.1"
+
+rocambole-node@~1.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/rocambole-node/-/rocambole-node-1.0.0.tgz#db5b49de7407b0080dd514872f28e393d0f7ff3f"
+
+rocambole-token@^1.1.2, rocambole-token@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/rocambole-token/-/rocambole-token-1.2.1.tgz#c785df7428dc3cb27ad7897047bd5238cc070d35"
+
+rocambole-whitespace@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/rocambole-whitespace/-/rocambole-whitespace-1.0.0.tgz#63330949256b29941f59b190459f999c6b1d3bf9"
+ dependencies:
+ debug "^2.1.3"
+ repeat-string "^1.5.0"
+ rocambole-token "^1.2.1"
+
+"rocambole@>=0.7 <2.0", rocambole@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/rocambole/-/rocambole-0.7.0.tgz#f6c79505517dc42b6fb840842b8b953b0f968585"
+ dependencies:
+ esprima "^2.1"
+
+run-async@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
+ dependencies:
+ is-promise "^2.1.0"
+
+run-queue@^1.0.0, run-queue@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47"
+ dependencies:
+ aproba "^1.1.1"
+
+run-sequence@2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/run-sequence/-/run-sequence-2.2.1.tgz#1ce643da36fd8c7ea7e1a9329da33fc2b8898495"
+ dependencies:
+ chalk "^1.1.3"
+ fancy-log "^1.3.2"
+ plugin-error "^0.1.2"
+
+rxjs@^6.1.0:
+ version "6.3.2"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.2.tgz#6a688b16c4e6e980e62ea805ec30648e1c60907f"
+ dependencies:
+ tslib "^1.9.0"
+
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
+
+safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+
+safe-json-parse@~1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/safe-json-parse/-/safe-json-parse-1.0.1.tgz#3e76723e38dfdda13c9b1d29a1e07ffee4b30b57"
+
+safe-regex@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
+ dependencies:
+ ret "~0.1.10"
+
+"safer-buffer@>= 2.1.2 < 3":
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+
+sane@^1.6.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/sane/-/sane-1.7.0.tgz#b3579bccb45c94cf20355cc81124990dfd346e30"
+ dependencies:
+ anymatch "^1.3.0"
+ exec-sh "^0.2.0"
+ fb-watchman "^2.0.0"
+ minimatch "^3.0.2"
+ minimist "^1.1.1"
+ walker "~1.0.5"
+ watch "~0.10.0"
+
+sax@~1.2.1:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
+
+schedule@^0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/schedule/-/schedule-0.5.0.tgz#c128fffa0b402488b08b55ae74bb9df55cc29cc8"
+ dependencies:
+ object-assign "^4.1.1"
+
+schema-utils@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf"
+ dependencies:
+ ajv "^5.0.0"
+
+schema-utils@^0.4.5:
+ version "0.4.5"
+ resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e"
+ dependencies:
+ ajv "^6.1.0"
+ ajv-keywords "^3.1.0"
+
+schema-utils@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770"
+ dependencies:
+ ajv "^6.1.0"
+ ajv-errors "^1.0.0"
+ ajv-keywords "^3.1.0"
+
+section-iterator@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a"
+
+select@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
+
+"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1:
+ version "5.5.1"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477"
+
+semver@^4.1.0, semver@^4.3.1:
+ version "4.3.6"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da"
+
+sequencify@~0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/sequencify/-/sequencify-0.0.7.tgz#90cff19d02e07027fd767f5ead3e7b95d1e7380c"
+
+serialize-javascript@^1.4.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.5.0.tgz#1aa336162c88a890ddad5384baebc93a655161fe"
+
+serializerr@^1.0.1:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/serializerr/-/serializerr-1.0.3.tgz#12d4c5aa1c3ffb8f6d1dc5f395aa9455569c3f91"
+ dependencies:
+ protochain "^1.0.5"
+
+set-blocking@^2.0.0, set-blocking@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+
+set-immediate-shim@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
+
+set-value@^0.4.3:
+ version "0.4.3"
+ resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-extendable "^0.1.1"
+ is-plain-object "^2.0.1"
+ to-object-path "^0.3.0"
+
+set-value@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-extendable "^0.1.1"
+ is-plain-object "^2.0.3"
+ split-string "^3.0.1"
+
+setimmediate@^1.0.4, setimmediate@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
+
+sha.js@^2.4.0, sha.js@^2.4.8:
+ version "2.4.9"
+ resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.9.tgz#98f64880474b74f4a38b8da9d3c0f2d104633e7d"
+ dependencies:
+ inherits "^2.0.1"
+ safe-buffer "^5.0.1"
+
+shallow-equal@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7"
+
+shallowequal@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.0.2.tgz#1561dbdefb8c01408100319085764da3fcf83f8f"
+
+shallowequal@^1.0.2:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
+
+shebang-command@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+ dependencies:
+ shebang-regex "^1.0.0"
+
+shebang-regex@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+
+sigmund@~1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590"
+
+signal-exit@^3.0.0, signal-exit@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+
+signalr@2.4.0:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/signalr/-/signalr-2.4.0.tgz#92af008e6b527ad4b36e94fbb340e135087a60d2"
+ dependencies:
+ jquery ">=1.6.4"
+
+slash@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
+
+slice-ansi@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"
+ dependencies:
+ is-fullwidth-code-point "^2.0.0"
+
+snapdragon-node@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
+ dependencies:
+ define-property "^1.0.0"
+ isobject "^3.0.0"
+ snapdragon-util "^3.0.1"
+
+snapdragon-util@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
+ dependencies:
+ kind-of "^3.2.0"
+
+snapdragon@^0.8.1:
+ version "0.8.2"
+ resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
+ dependencies:
+ base "^0.11.1"
+ debug "^2.2.0"
+ define-property "^0.2.5"
+ extend-shallow "^2.0.1"
+ map-cache "^0.2.2"
+ source-map "^0.5.6"
+ source-map-resolve "^0.5.0"
+ use "^3.1.0"
+
+sntp@2.x.x:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.0.2.tgz#5064110f0af85f7cfdb7d6b67a40028ce52b4b2b"
+ dependencies:
+ hoek "4.x.x"
+
+sort-keys@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
+ dependencies:
+ is-plain-obj "^1.0.0"
+
+source-list-map@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085"
+
+source-map-resolve@^0.3.0:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.3.1.tgz#610f6122a445b8dd51535a2a71b783dfc1248761"
+ dependencies:
+ atob "~1.1.0"
+ resolve-url "~0.2.1"
+ source-map-url "~0.3.0"
+ urix "~0.1.0"
+
+source-map-resolve@^0.5.0:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259"
+ dependencies:
+ atob "^2.1.1"
+ decode-uri-component "^0.2.0"
+ resolve-url "^0.2.1"
+ source-map-url "^0.4.0"
+ urix "^0.1.0"
+
+source-map-support@^0.4.15:
+ version "0.4.18"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f"
+ dependencies:
+ source-map "^0.5.6"
+
+source-map-url@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
+
+source-map-url@~0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.3.0.tgz#7ecaf13b57bcd09da8a40c5d269db33799d4aaf9"
+
+source-map@^0.1.38:
+ version "0.1.43"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346"
+ dependencies:
+ amdefine ">=0.0.4"
+
+source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1, source-map@~0.5.3:
+ version "0.5.7"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+
+source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+
+sparkles@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.0.tgz#1acbbfb592436d10bbe8f785b7cc6f82815012c3"
+
+spdx-correct@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.0.tgz#05a5b4d7153a195bc92c3c425b69f3b2a9524c82"
+ dependencies:
+ spdx-expression-parse "^3.0.0"
+ spdx-license-ids "^3.0.0"
+
+spdx-exceptions@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz#2c7ae61056c714a5b9b9b2b2af7d311ef5c78fe9"
+
+spdx-expression-parse@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
+ dependencies:
+ spdx-exceptions "^2.1.0"
+ spdx-license-ids "^3.0.0"
+
+spdx-license-ids@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz#e2a303236cac54b04031fa7a5a79c7e701df852f"
+
+specificity@^0.4.0:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019"
+
+split-string@^3.0.1, split-string@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
+ dependencies:
+ extend-shallow "^3.0.0"
+
+split@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9"
+ dependencies:
+ through "2"
+
+sprintf-js@~1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
+
+sshpk@^1.7.0:
+ version "1.13.1"
+ resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3"
+ dependencies:
+ asn1 "~0.2.3"
+ assert-plus "^1.0.0"
+ dashdash "^1.12.0"
+ getpass "^0.1.1"
+ optionalDependencies:
+ bcrypt-pbkdf "^1.0.0"
+ ecc-jsbn "~0.1.1"
+ jsbn "~0.1.0"
+ tweetnacl "~0.14.0"
+
+ssri@^5.2.4:
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.3.0.tgz#ba3872c9c6d33a0704a7d71ff045e5ec48999d06"
+ dependencies:
+ safe-buffer "^5.1.1"
+
+state-toggle@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.1.tgz#c3cb0974f40a6a0f8e905b96789eb41afa1cde3a"
+
+static-extend@^0.1.1:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
+ dependencies:
+ define-property "^0.2.5"
+ object-copy "^0.1.0"
+
+stdin@*:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/stdin/-/stdin-0.0.1.tgz#d3041981aaec3dfdbc77a1b38d6372e38f5fb71e"
+
+stream-browserify@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"
+ dependencies:
+ inherits "~2.0.1"
+ readable-stream "^2.0.2"
+
+stream-combiner@^0.2.2:
+ version "0.2.2"
+ resolved "http://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz#aec8cbac177b56b6f4fa479ced8c1912cee52858"
+ dependencies:
+ duplexer "~0.1.1"
+ through "~2.3.4"
+
+stream-consume@~0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f"
+
+stream-each@^1.1.0:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd"
+ dependencies:
+ end-of-stream "^1.1.0"
+ stream-shift "^1.0.0"
+
+stream-http@^2.3.1:
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.7.2.tgz#40a050ec8dc3b53b33d9909415c02c0bf1abfbad"
+ dependencies:
+ builtin-status-codes "^3.0.0"
+ inherits "^2.0.1"
+ readable-stream "^2.2.6"
+ to-arraybuffer "^1.0.0"
+ xtend "^4.0.0"
+
+stream-shift@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
+
+streamqueue@1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/streamqueue/-/streamqueue-1.1.2.tgz#6c99c7c20d62b57f5819296bf9ec942542380192"
+ dependencies:
+ isstream "^0.1.2"
+ readable-stream "^2.3.3"
+
+strict-uri-encode@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
+
+string-template@~0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
+
+string-width@^1.0.1, string-width@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+ dependencies:
+ code-point-at "^1.0.0"
+ is-fullwidth-code-point "^1.0.0"
+ strip-ansi "^3.0.0"
+
+string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+ dependencies:
+ is-fullwidth-code-point "^2.0.0"
+ strip-ansi "^4.0.0"
+
+string.prototype.trim@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea"
+ dependencies:
+ define-properties "^1.1.2"
+ es-abstract "^1.5.0"
+ function-bind "^1.0.2"
+
+string_decoder@0.10, string_decoder@^0.10.25, string_decoder@~0.10.x:
+ version "0.10.31"
+ resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
+
+string_decoder@~1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
+ dependencies:
+ safe-buffer "~5.1.0"
+
+string_decoder@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+ dependencies:
+ safe-buffer "~5.1.0"
+
+stringify-entities@^1.0.1:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-1.3.2.tgz#a98417e5471fd227b3e45d3db1861c11caf668f7"
+ dependencies:
+ character-entities-html4 "^1.0.0"
+ character-entities-legacy "^1.0.0"
+ is-alphanumerical "^1.0.0"
+ is-hexadecimal "^1.0.0"
+
+stringstream@~0.0.5:
+ version "0.0.5"
+ resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
+
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+ dependencies:
+ ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+ dependencies:
+ ansi-regex "^3.0.0"
+
+strip-bom-stream@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca"
+ dependencies:
+ first-chunk-stream "^2.0.0"
+ strip-bom "^2.0.0"
+
+strip-bom-string@1.X:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92"
+
+strip-bom@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-1.0.0.tgz#85b8862f3844b5a6d5ec8467a93598173a36f794"
+ dependencies:
+ first-chunk-stream "^1.0.0"
+ is-utf8 "^0.2.0"
+
+strip-bom@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
+ dependencies:
+ is-utf8 "^0.2.0"
+
+strip-bom@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
+
+strip-eof@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
+
+strip-indent@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
+
+strip-json-comments@^2.0.1, strip-json-comments@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+
+strip-json-comments@~0.1.1:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-0.1.3.tgz#164c64e370a8a3cc00c9e01b539e569823f0ee54"
+
+style-loader@0.19.1:
+ version "0.19.1"
+ resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.1.tgz#591ffc80bcefe268b77c5d9ebc0505d772619f85"
+ dependencies:
+ loader-utils "^1.0.2"
+ schema-utils "^0.3.0"
+
+style-search@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902"
+
+stylelint-order@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/stylelint-order/-/stylelint-order-1.0.0.tgz#089fc3d5cdf7e7d4ac1882f65b60b25db750413c"
+ dependencies:
+ lodash "^4.17.10"
+ postcss "^7.0.2"
+ postcss-sorting "^4.0.0"
+
+stylelint@9.5.0:
+ version "9.5.0"
+ resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-9.5.0.tgz#f7afb45342abc4acf28a8da8a48373e9f79c1fb4"
+ dependencies:
+ autoprefixer "^9.0.0"
+ balanced-match "^1.0.0"
+ chalk "^2.4.1"
+ cosmiconfig "^5.0.0"
+ debug "^3.0.0"
+ execall "^1.0.0"
+ file-entry-cache "^2.0.0"
+ get-stdin "^6.0.0"
+ globby "^8.0.0"
+ globjoin "^0.1.4"
+ html-tags "^2.0.0"
+ ignore "^4.0.0"
+ import-lazy "^3.1.0"
+ imurmurhash "^0.1.4"
+ known-css-properties "^0.6.0"
+ lodash "^4.17.4"
+ log-symbols "^2.0.0"
+ mathml-tag-names "^2.0.1"
+ meow "^5.0.0"
+ micromatch "^2.3.11"
+ normalize-selector "^0.2.0"
+ pify "^4.0.0"
+ postcss "^7.0.0"
+ postcss-html "^0.33.0"
+ postcss-jsx "^0.33.0"
+ postcss-less "^2.0.0"
+ postcss-markdown "^0.33.0"
+ postcss-media-query-parser "^0.2.3"
+ postcss-reporter "^5.0.0"
+ postcss-resolve-nested-selector "^0.1.1"
+ postcss-safe-parser "^4.0.0"
+ postcss-sass "^0.3.0"
+ postcss-scss "^2.0.0"
+ postcss-selector-parser "^3.1.0"
+ postcss-styled "^0.33.0"
+ postcss-syntax "^0.33.0"
+ postcss-value-parser "^3.3.0"
+ resolve-from "^4.0.0"
+ signal-exit "^3.0.2"
+ specificity "^0.4.0"
+ string-width "^2.1.0"
+ style-search "^0.1.0"
+ sugarss "^2.0.0"
+ svg-tags "^1.0.0"
+ table "^4.0.1"
+
+sugarss@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-1.0.0.tgz#65e51b3958432fb70d5451a68bb33e32d0cf1ef7"
+ dependencies:
+ postcss "^6.0.0"
+
+sugarss@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-2.0.0.tgz#ddd76e0124b297d40bf3cca31c8b22ecb43bc61d"
+ dependencies:
+ postcss "^7.0.2"
+
+supports-color@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-1.3.1.tgz#15758df09d8ff3b4acc307539fabe27095e1042d"
+
+supports-color@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
+
+supports-color@^3.2.3:
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6"
+ dependencies:
+ has-flag "^1.0.0"
+
+supports-color@^4.2.1:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e"
+ dependencies:
+ has-flag "^2.0.0"
+
+supports-color@^4.4.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b"
+ dependencies:
+ has-flag "^2.0.0"
+
+supports-color@^5.3.0, supports-color@^5.4.0:
+ version "5.5.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+ dependencies:
+ has-flag "^3.0.0"
+
+svg-tags@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
+
+svgo@^0.7.0:
+ version "0.7.2"
+ resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5"
+ dependencies:
+ coa "~1.0.1"
+ colors "~1.1.2"
+ csso "~2.3.1"
+ js-yaml "~3.7.0"
+ mkdirp "~0.5.1"
+ sax "~1.2.1"
+ whet.extend "~0.9.9"
+
+symbol-observable@^1.0.4, symbol-observable@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+
+sync-exec@^0.6.2:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105"
+
+table@^4.0.1, table@^4.0.3:
+ version "4.0.3"
+ resolved "http://registry.npmjs.org/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc"
+ dependencies:
+ ajv "^6.0.1"
+ ajv-keywords "^3.0.0"
+ chalk "^2.1.0"
+ lodash "^4.17.4"
+ slice-ansi "1.0.0"
+ string-width "^2.1.1"
+
+tapable@^0.2.7:
+ version "0.2.8"
+ resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22"
+
+tape@^4.6.3:
+ version "4.8.0"
+ resolved "https://registry.yarnpkg.com/tape/-/tape-4.8.0.tgz#f6a9fec41cc50a1de50fa33603ab580991f6068e"
+ dependencies:
+ deep-equal "~1.0.1"
+ defined "~1.0.0"
+ for-each "~0.3.2"
+ function-bind "~1.1.0"
+ glob "~7.1.2"
+ has "~1.0.1"
+ inherits "~2.0.3"
+ minimist "~1.2.0"
+ object-inspect "~1.3.0"
+ resolve "~1.4.0"
+ resumer "~0.0.0"
+ string.prototype.trim "~1.1.2"
+ through "~2.3.8"
+
+tar-pack@^3.4.0:
+ version "3.4.0"
+ resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984"
+ dependencies:
+ debug "^2.2.0"
+ fstream "^1.0.10"
+ fstream-ignore "^1.0.5"
+ once "^1.3.3"
+ readable-stream "^2.1.4"
+ rimraf "^2.5.1"
+ tar "^2.2.1"
+ uid-number "^0.0.6"
+
+tar.gz@1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/tar.gz/-/tar.gz-1.0.7.tgz#577ef2c595faaa73452ef0415fed41113212257b"
+ dependencies:
+ bluebird "^2.9.34"
+ commander "^2.8.1"
+ fstream "^1.0.8"
+ mout "^0.11.0"
+ tar "^2.1.1"
+
+tar@^2.1.1, tar@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1"
+ dependencies:
+ block-stream "*"
+ fstream "^1.0.2"
+ inherits "2"
+
+tether@^1.4.3:
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/tether/-/tether-1.4.3.tgz#fd547024c47b6e5c9b87e1880f997991a9a6ad54"
+
+text-table@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+
+through2@2.0.3, through2@2.X, through2@^2.0.0, through2@^2.0.1, through2@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be"
+ dependencies:
+ readable-stream "^2.1.5"
+ xtend "~4.0.1"
+
+through2@^0.4.1:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/through2/-/through2-0.4.2.tgz#dbf5866031151ec8352bb6c4db64a2292a840b9b"
+ dependencies:
+ readable-stream "~1.0.17"
+ xtend "~2.1.1"
+
+through2@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/through2/-/through2-0.5.1.tgz#dfdd012eb9c700e2323fd334f38ac622ab372da7"
+ dependencies:
+ readable-stream "~1.0.17"
+ xtend "~3.0.0"
+
+through2@^0.6.1:
+ version "0.6.5"
+ resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
+ dependencies:
+ readable-stream ">=1.0.33-1 <1.1.0-0"
+ xtend ">=4.0.0 <4.1.0-0"
+
+through@2, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.4, through@~2.3.8:
+ version "2.3.8"
+ resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+
+tildify@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/tildify/-/tildify-1.2.0.tgz#dcec03f55dca9b7aa3e5b04f21817eb56e63588a"
+ dependencies:
+ os-homedir "^1.0.0"
+
+time-stamp@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3"
+
+timers-browserify@^2.0.2:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.4.tgz#96ca53f4b794a5e7c0e1bd7cc88a372298fa01e6"
+ dependencies:
+ setimmediate "^1.0.4"
+
+timers-ext@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.2.tgz#61cc47a76c1abd3195f14527f978d58ae94c5204"
+ dependencies:
+ es5-ext "~0.10.14"
+ next-tick "1"
+
+tiny-emitter@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c"
+
+tiny-lr@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab"
+ dependencies:
+ body "^5.1.0"
+ debug "^3.1.0"
+ faye-websocket "~0.10.0"
+ livereload-js "^2.3.0"
+ object-assign "^4.1.0"
+ qs "^6.4.0"
+
+tmp@^0.0.33:
+ version "0.0.33"
+ resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+ dependencies:
+ os-tmpdir "~1.0.2"
+
+tmpl@1.0.x:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
+
+to-arraybuffer@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
+
+to-camel-case@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46"
+ dependencies:
+ to-space-case "^1.0.0"
+
+to-fast-properties@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
+
+to-fast-properties@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
+
+to-no-case@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a"
+
+to-object-path@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
+ dependencies:
+ kind-of "^3.0.2"
+
+to-regex-range@^2.1.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
+ dependencies:
+ is-number "^3.0.0"
+ repeat-string "^1.6.1"
+
+to-regex@^3.0.1, to-regex@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
+ dependencies:
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ regex-not "^1.0.2"
+ safe-regex "^1.1.0"
+
+to-space-case@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17"
+ dependencies:
+ to-no-case "^1.0.0"
+
+tough-cookie@~2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a"
+ dependencies:
+ punycode "^1.4.1"
+
+traverse@~0.6.3:
+ version "0.6.6"
+ resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137"
+
+trim-newlines@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-2.0.0.tgz#b403d0b91be50c331dfc4b82eeceb22c3de16d20"
+
+trim-right@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
+
+trim-trailing-lines@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.1.tgz#e0ec0810fd3c3f1730516b45f49083caaf2774d9"
+
+trim@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd"
+
+trough@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.3.tgz#e29bd1614c6458d44869fc28b255ab7857ef7c24"
+
+tryit@^1.0.1:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb"
+
+tslib@^1.9.0:
+ version "1.9.3"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
+
+tty-browserify@0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
+
+tunnel-agent@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+ dependencies:
+ safe-buffer "^5.0.1"
+
+tweetnacl@^0.14.3, tweetnacl@~0.14.0:
+ version "0.14.5"
+ resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
+
+type-check@~0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
+ dependencies:
+ prelude-ls "~1.1.2"
+
+typedarray@^0.0.6:
+ version "0.0.6"
+ resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
+
+ua-parser-js@^0.7.18, ua-parser-js@^0.7.9:
+ version "0.7.18"
+ resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed"
+
+uglify-es@^3.3.4:
+ version "3.3.9"
+ resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"
+ dependencies:
+ commander "~2.13.0"
+ source-map "~0.6.1"
+
+uglify-js@^2.8.29:
+ version "2.8.29"
+ resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
+ dependencies:
+ source-map "~0.5.1"
+ yargs "~3.10.0"
+ optionalDependencies:
+ uglify-to-browserify "~1.0.0"
+
+uglify-to-browserify@~1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7"
+
+uglifyjs-webpack-plugin@1.2.5:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.2.5.tgz#2ef8387c8f1a903ec5e44fa36f9f3cbdcea67641"
+ dependencies:
+ cacache "^10.0.4"
+ find-cache-dir "^1.0.0"
+ schema-utils "^0.4.5"
+ serialize-javascript "^1.4.0"
+ source-map "^0.6.1"
+ uglify-es "^3.3.4"
+ webpack-sources "^1.1.0"
+ worker-farm "^1.5.2"
+
+uglifyjs-webpack-plugin@^0.4.6:
+ version "0.4.6"
+ resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz#b951f4abb6bd617e66f63eb891498e391763e309"
+ dependencies:
+ source-map "^0.5.6"
+ uglify-js "^2.8.29"
+ webpack-sources "^1.0.1"
+
+uid-number@^0.0.6:
+ version "0.0.6"
+ resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
+
+unc-path-regex@^0.1.0:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
+
+unherit@^1.0.4:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.1.tgz#132748da3e88eab767e08fabfbb89c5e9d28628c"
+ dependencies:
+ inherits "^2.0.1"
+ xtend "^4.0.1"
+
+unified@^6.0.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba"
+ dependencies:
+ bail "^1.0.0"
+ extend "^3.0.0"
+ is-plain-obj "^1.1.0"
+ trough "^1.0.0"
+ vfile "^2.0.0"
+ x-is-string "^0.1.0"
+
+union-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"
+ dependencies:
+ arr-union "^3.1.0"
+ get-value "^2.0.6"
+ is-extendable "^0.1.1"
+ set-value "^0.4.3"
+
+uniq@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
+
+uniqid@^4.0.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/uniqid/-/uniqid-4.1.1.tgz#89220ddf6b751ae52b5f72484863528596bb84c1"
+ dependencies:
+ macaddress "^0.2.8"
+
+uniqs@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02"
+
+unique-filename@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3"
+ dependencies:
+ unique-slug "^2.0.0"
+
+unique-slug@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab"
+ dependencies:
+ imurmurhash "^0.1.4"
+
+unique-stream@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-1.0.0.tgz#d59a4a75427447d9aa6c91e70263f8d26a4b104b"
+
+unist-util-find-all-after@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.2.tgz#9be49cfbae5ca1566b27536670a92836bf2f8d6d"
+ dependencies:
+ unist-util-is "^2.0.0"
+
+unist-util-is@^2.0.0, unist-util-is@^2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.2.tgz#1193fa8f2bfbbb82150633f3a8d2eb9a1c1d55db"
+
+unist-util-remove-position@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-1.1.2.tgz#86b5dad104d0bbfbeb1db5f5c92f3570575c12cb"
+ dependencies:
+ unist-util-visit "^1.1.0"
+
+unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6"
+
+unist-util-visit-parents@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-2.0.1.tgz#63fffc8929027bee04bfef7d2cce474f71cb6217"
+ dependencies:
+ unist-util-is "^2.1.2"
+
+unist-util-visit@^1.1.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.0.tgz#1cb763647186dc26f5e1df5db6bd1e48b3cc2fb1"
+ dependencies:
+ unist-util-visit-parents "^2.0.0"
+
+unset-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
+ dependencies:
+ has-value "^0.3.1"
+ isobject "^3.0.0"
+
+uri-js@^4.2.1, uri-js@^4.2.2:
+ version "4.2.2"
+ resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
+ dependencies:
+ punycode "^2.1.0"
+
+urix@^0.1.0, urix@~0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
+
+url-loader@0.6.2:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.6.2.tgz#a007a7109620e9d988d14bce677a1decb9a993f7"
+ dependencies:
+ loader-utils "^1.0.2"
+ mime "^1.4.1"
+ schema-utils "^0.3.0"
+
+url@^0.11.0:
+ version "0.11.0"
+ resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
+ dependencies:
+ punycode "1.3.2"
+ querystring "0.2.0"
+
+use@^3.1.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
+
+user-home@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190"
+
+user-home@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f"
+ dependencies:
+ os-homedir "^1.0.0"
+
+util-deprecate@~1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+
+util@0.10.3, util@^0.10.3:
+ version "0.10.3"
+ resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9"
+ dependencies:
+ inherits "2.0.1"
+
+uuid@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
+
+v8flags@^2.0.2:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4"
+ dependencies:
+ user-home "^1.1.1"
+
+validate-npm-package-license@^3.0.1:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
+ dependencies:
+ spdx-correct "^3.0.0"
+ spdx-expression-parse "^3.0.0"
+
+value-equal@^0.2.0:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.1.tgz#c220a304361fce6994dbbedaa3c7e1a1b895871d"
+
+value-equal@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7"
+
+vendors@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22"
+
+verror@1.10.0:
+ version "1.10.0"
+ resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
+ dependencies:
+ assert-plus "^1.0.0"
+ core-util-is "1.0.2"
+ extsprintf "^1.2.0"
+
+vfile-location@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.3.tgz#083ba80e50968e8d420be49dd1ea9a992131df77"
+
+vfile-message@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.0.1.tgz#51a2ccd8a6b97a7980bb34efb9ebde9632e93677"
+ dependencies:
+ unist-util-stringify-position "^1.1.1"
+
+vfile@^2.0.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a"
+ dependencies:
+ is-buffer "^1.1.4"
+ replace-ext "1.0.0"
+ unist-util-stringify-position "^1.0.0"
+ vfile-message "^1.0.0"
+
+vinyl-bufferstream@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz#0537869f580effa4ca45acb47579e4b9fe63081a"
+ dependencies:
+ bufferstreams "1.0.1"
+
+vinyl-file@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a"
+ dependencies:
+ graceful-fs "^4.1.2"
+ pify "^2.3.0"
+ pinkie-promise "^2.0.0"
+ strip-bom "^2.0.0"
+ strip-bom-stream "^2.0.0"
+ vinyl "^1.1.0"
+
+vinyl-fs@^0.3.0:
+ version "0.3.14"
+ resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-0.3.14.tgz#9a6851ce1cac1c1cea5fe86c0931d620c2cfa9e6"
+ dependencies:
+ defaults "^1.0.0"
+ glob-stream "^3.1.5"
+ glob-watcher "^0.0.6"
+ graceful-fs "^3.0.0"
+ mkdirp "^0.5.0"
+ strip-bom "^1.0.0"
+ through2 "^0.6.1"
+ vinyl "^0.4.0"
+
+vinyl-map@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/vinyl-map/-/vinyl-map-1.0.2.tgz#a8b296025f973fa7cad62817967a48f1d176bf7c"
+ dependencies:
+ bl "^1.1.2"
+ new-from "0.0.3"
+ through2 "^0.4.1"
+
+vinyl-sourcemaps-apply@0.2.1, vinyl-sourcemaps-apply@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz#ab6549d61d172c2b1b87be5c508d239c8ef87705"
+ dependencies:
+ source-map "^0.5.1"
+
+vinyl@^0.4.0:
+ version "0.4.6"
+ resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-0.4.6.tgz#2f356c87a550a255461f36bbeb2a5ba8bf784847"
+ dependencies:
+ clone "^0.2.0"
+ clone-stats "^0.0.1"
+
+vinyl@^0.5.0:
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-0.5.3.tgz#b0455b38fc5e0cf30d4325132e461970c2091cde"
+ dependencies:
+ clone "^1.0.0"
+ clone-stats "^0.0.1"
+ replace-ext "0.0.1"
+
+vinyl@^1.1.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
+ dependencies:
+ clone "^1.0.0"
+ clone-stats "^0.0.1"
+ replace-ext "0.0.1"
+
+vinyl@^2.0.0, vinyl@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.1.0.tgz#021f9c2cf951d6b939943c89eb5ee5add4fd924c"
+ dependencies:
+ clone "^2.1.1"
+ clone-buffer "^1.0.0"
+ clone-stats "^1.0.0"
+ cloneable-readable "^1.0.0"
+ remove-trailing-separator "^1.0.1"
+ replace-ext "^1.0.0"
+
+vinyl@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86"
+ dependencies:
+ clone "^2.1.1"
+ clone-buffer "^1.0.0"
+ clone-stats "^1.0.0"
+ cloneable-readable "^1.0.0"
+ remove-trailing-separator "^1.0.1"
+ replace-ext "^1.0.0"
+
+vm-browserify@0.0.4:
+ version "0.0.4"
+ resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73"
+ dependencies:
+ indexof "0.0.1"
+
+walker@~1.0.5:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"
+ dependencies:
+ makeerror "1.0.x"
+
+warning@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
+ dependencies:
+ loose-envify "^1.0.0"
+
+warning@^4.0.1:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.2.tgz#aa6876480872116fa3e11d434b0d0d8d91e44607"
+ dependencies:
+ loose-envify "^1.0.0"
+
+watch@~0.10.0:
+ version "0.10.0"
+ resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc"
+
+watchpack@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac"
+ dependencies:
+ async "^2.1.2"
+ chokidar "^1.7.0"
+ graceful-fs "^4.1.2"
+
+weak@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/weak/-/weak-1.0.1.tgz#ab99aab30706959aa0200cb8cf545bb9cb33b99e"
+ dependencies:
+ bindings "^1.2.1"
+ nan "^2.0.5"
+
+webpack-sources@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.0.1.tgz#c7356436a4d13123be2e2426a05d1dad9cbe65cf"
+ dependencies:
+ source-list-map "^2.0.0"
+ source-map "~0.5.3"
+
+webpack-sources@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.1.0.tgz#a101ebae59d6507354d71d8013950a3a8b7a5a54"
+ dependencies:
+ source-list-map "^2.0.0"
+ source-map "~0.6.1"
+
+webpack-stream@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/webpack-stream/-/webpack-stream-4.0.0.tgz#f3673dd907d6d9b1ea7bf51fcd1db85b5fd9e0f2"
+ dependencies:
+ gulp-util "^3.0.7"
+ lodash.clone "^4.3.2"
+ lodash.some "^4.2.2"
+ memory-fs "^0.4.1"
+ through "^2.3.8"
+ vinyl "^2.1.0"
+ webpack "^3.4.1"
+
+webpack@3.10.0:
+ version "3.10.0"
+ resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.10.0.tgz#5291b875078cf2abf42bdd23afe3f8f96c17d725"
+ dependencies:
+ acorn "^5.0.0"
+ acorn-dynamic-import "^2.0.0"
+ ajv "^5.1.5"
+ ajv-keywords "^2.0.0"
+ async "^2.1.2"
+ enhanced-resolve "^3.4.0"
+ escope "^3.6.0"
+ interpret "^1.0.0"
+ json-loader "^0.5.4"
+ json5 "^0.5.1"
+ loader-runner "^2.3.0"
+ loader-utils "^1.1.0"
+ memory-fs "~0.4.1"
+ mkdirp "~0.5.0"
+ node-libs-browser "^2.0.0"
+ source-map "^0.5.3"
+ supports-color "^4.2.1"
+ tapable "^0.2.7"
+ uglifyjs-webpack-plugin "^0.4.6"
+ watchpack "^1.4.0"
+ webpack-sources "^1.0.1"
+ yargs "^8.0.2"
+
+webpack@^3.4.1:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.6.0.tgz#a89a929fbee205d35a4fa2cc487be9cbec8898bc"
+ dependencies:
+ acorn "^5.0.0"
+ acorn-dynamic-import "^2.0.0"
+ ajv "^5.1.5"
+ ajv-keywords "^2.0.0"
+ async "^2.1.2"
+ enhanced-resolve "^3.4.0"
+ escope "^3.6.0"
+ interpret "^1.0.0"
+ json-loader "^0.5.4"
+ json5 "^0.5.1"
+ loader-runner "^2.3.0"
+ loader-utils "^1.1.0"
+ memory-fs "~0.4.1"
+ mkdirp "~0.5.0"
+ node-libs-browser "^2.0.0"
+ source-map "^0.5.3"
+ supports-color "^4.2.1"
+ tapable "^0.2.7"
+ uglifyjs-webpack-plugin "^0.4.6"
+ watchpack "^1.4.0"
+ webpack-sources "^1.0.1"
+ yargs "^8.0.2"
+
+websocket-driver@>=0.5.1:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb"
+ dependencies:
+ http-parser-js ">=0.4.0"
+ websocket-extensions ">=0.1.1"
+
+websocket-extensions@>=0.1.1:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.2.tgz#0e18781de629a18308ce1481650f67ffa2693a5d"
+
+whatwg-fetch@>=0.10.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
+
+whet.extend@~0.9.9:
+ version "0.9.9"
+ resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"
+
+which-module@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
+
+which@^1.0.5, which@^1.2.12, which@^1.2.4, which@^1.2.9:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a"
+ dependencies:
+ isexe "^2.0.0"
+
+wide-align@^1.1.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710"
+ dependencies:
+ string-width "^1.0.2"
+
+window-size@0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
+
+wordwrap@0.0.2:
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"
+
+wordwrap@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
+
+worker-farm@^1.3.1:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.0.tgz#adfdf0cd40581465ed0a1f648f9735722afd5c8d"
+ dependencies:
+ errno "^0.1.4"
+ xtend "^4.0.1"
+
+worker-farm@^1.5.2:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.6.0.tgz#aecc405976fab5a95526180846f0dba288f3a4a0"
+ dependencies:
+ errno "~0.1.7"
+
+wrap-ansi@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"
+ dependencies:
+ string-width "^1.0.1"
+ strip-ansi "^3.0.1"
+
+wrappy@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+
+write@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757"
+ dependencies:
+ mkdirp "^0.5.1"
+
+x-is-string@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"
+
+"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
+
+xtend@~2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/xtend/-/xtend-2.1.2.tgz#6efecc2a4dad8e6962c4901b337ce7ba87b5d28b"
+ dependencies:
+ object-keys "~0.4.0"
+
+xtend@~3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/xtend/-/xtend-3.0.0.tgz#5cce7407baf642cba7becda568111c493f59665a"
+
+y18n@^3.2.1:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
+
+y18n@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
+
+yallist@^2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
+
+yargs-parser@^10.0.0:
+ version "10.1.0"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8"
+ dependencies:
+ camelcase "^4.1.0"
+
+yargs-parser@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9"
+ dependencies:
+ camelcase "^4.1.0"
+
+yargs@^8.0.1, yargs@^8.0.2:
+ version "8.0.2"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360"
+ dependencies:
+ camelcase "^4.1.0"
+ cliui "^3.2.0"
+ decamelize "^1.1.1"
+ get-caller-file "^1.0.1"
+ os-locale "^2.0.0"
+ read-pkg-up "^2.0.0"
+ require-directory "^2.1.1"
+ require-main-filename "^1.0.1"
+ set-blocking "^2.0.0"
+ string-width "^2.0.0"
+ which-module "^2.0.0"
+ y18n "^3.2.1"
+ yargs-parser "^7.0.0"
+
+yargs@~3.10.0:
+ version "3.10.0"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"
+ dependencies:
+ camelcase "^1.0.2"
+ cliui "^2.1.0"
+ decamelize "^1.0.0"
+ window-size "0.1.0"