diff --git a/.gitignore b/.gitignore
index a340f3295..e52e5375c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -120,7 +120,7 @@ _tests/
setup/Output/
*.~is
-UI.Phantom/
+UI/
#VS outout folders
bin
@@ -135,5 +135,3 @@ _start
_temp_*/**/*
src/.idea/
-/npm_start.bat
-/npm_start.bat
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index e402c1d9d..000000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-Lidarr
\ No newline at end of file
diff --git a/.idea/Sonarr.iml b/.idea/Sonarr.iml
deleted file mode 100644
index aeec84bf6..000000000
--- a/.idea/Sonarr.iml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml
deleted file mode 100644
index 7598f4c8e..000000000
--- a/.idea/codeStyleSettings.xml
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
deleted file mode 100644
index 97626ba45..000000000
--- a/.idea/encodings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml
deleted file mode 100644
index b8387eb1b..000000000
--- a/.idea/jsLibraryMappings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/libraries/Sonarr_node_modules.xml b/.idea/libraries/Sonarr_node_modules.xml
deleted file mode 100644
index 4eeebc5cc..000000000
--- a/.idea/libraries/Sonarr_node_modules.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 19f74da8e..000000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 7cc2cf51b..000000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7f4..000000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
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.ps1 b/build.ps1
deleted file mode 100644
index 45b8ce783..000000000
--- a/build.ps1
+++ /dev/null
@@ -1 +0,0 @@
-Write-Warning "DEPRECATED -- Please use build.sh instead."
\ No newline at end of file
diff --git a/build.sh b/build.sh
index 1775ee34d..a61280bde 100755
--- a/build.sh
+++ b/build.sh
@@ -39,9 +39,6 @@ CleanFolder()
find $path -name "FluentValidation.resources.dll" -exec rm "{}" \;
find $path -name "App.config" -exec rm "{}" \;
- echo "Removing .less files"
- find $path -name "*.less" -exec rm "{}" \;
-
echo "Removing vshost files"
find $path -name "*.vshost.exe" -exec rm "{}" \;
@@ -102,11 +99,11 @@ Build()
RunGulp()
{
echo "##teamcity[progressStart 'npm install']"
- npm-cache install npm || CheckExitCode npm install
+ npm-cache install npm || CheckExitCode npm install --no-optional --no-bin-links
echo "##teamcity[progressFinish 'npm install']"
echo "##teamcity[progressStart 'Running gulp']"
- CheckExitCode npm run build
+ CheckExitCode npm run build -- --production
echo "##teamcity[progressFinish 'Running gulp']"
}
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..574918d05
--- /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" }],
+ 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", 4],
+ max-statements: "off",
+ max-statements-per-line: ["error", { "max": 1 }],
+ new-cap: ["error", {"capIsNewExceptions": ["$.Deferred"]}],
+ 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"],
+ padded-blocks: ["error", "never"],
+ quote-props: ["error", "consistent"],
+ 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-space-before-closing": 2,
+ "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", "eventHandlerPropPrefix": "on" }],
+ "react/jsx-no-undef": 2,
+ "react/jsx-pascal-case": 2,
+ "react/jsx-uses-react": 2,
+ "react/no-did-mount-set-state": 2,
+ "react/no-did-update-set-state": 2,
+ "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/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..5c7c74f43
--- /dev/null
+++ b/frontend/.stylelintrc
@@ -0,0 +1,392 @@
+{
+"plugins": [
+ "stylelint-order"
+],
+"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..d1d47c97e
--- /dev/null
+++ b/frontend/gulp/copy.js
@@ -0,0 +1,45 @@
+var path = require('path');
+var gulp = require('gulp');
+var print = require('gulp-print');
+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..744dd8d7e
--- /dev/null
+++ b/frontend/gulp/gulpFile.js
@@ -0,0 +1,8 @@
+require('./build.js');
+require('./clean.js');
+require('./copy.js');
+require('./imageMin.js');
+require('./start.js');
+require('./stripBom.js');
+require('./watch.js');
+require('./webpack.js');
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..b96b5aaeb
--- /dev/null
+++ b/frontend/gulp/helpers/paths.js
@@ -0,0 +1,23 @@
+const root = './frontend/src/';
+
+const paths = {
+ src: {
+ root,
+ html: root + '*.html',
+ scripts: root + '**/*.js',
+ content: root + 'Content/',
+ fonts: root + 'Content/Fonts/',
+ images: root + 'Content/Images/',
+ exclude: {
+ libs: `!${root}JsLibraries/**`
+ }
+ },
+ dest: {
+ root: './_output/UI/',
+ content: './_output/UI/Content/',
+ fonts: './_output/UI/Content/Fonts/',
+ images: './_output/UI/Content/Images/'
+ }
+};
+
+module.exports = paths;
diff --git a/frontend/gulp/imageMin.js b/frontend/gulp/imageMin.js
new file mode 100644
index 000000000..828143f28
--- /dev/null
+++ b/frontend/gulp/imageMin.js
@@ -0,0 +1,15 @@
+var gulp = require('gulp');
+var print = require('gulp-print');
+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/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..080b86dfe
--- /dev/null
+++ b/frontend/gulp/stripBom.js
@@ -0,0 +1,13 @@
+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.task('stripBom', () => {
+ stripBom(paths.src.root);
+});
diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js
new file mode 100644
index 000000000..dae893c38
--- /dev/null
+++ b/frontend/gulp/watch.js
@@ -0,0 +1,27 @@
+var gulp = require('gulp');
+var livereload = require('gulp-livereload');
+var watch = require('gulp-watch');
+var paths = require('./helpers/paths.js');
+
+require('./copy.js');
+require('./webpack.js');
+
+function watchTask(glob, task) {
+ var options = {
+ name: `watch: ${task}`,
+ verbose: true
+ };
+ return watch(glob, options, () => {
+ gulp.start(task);
+ });
+}
+
+gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => {
+ livereload.listen();
+
+ 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
new file mode 100644
index 000000000..f0c017aea
--- /dev/null
+++ b/frontend/gulp/webpack.js
@@ -0,0 +1,159 @@
+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 uiFolder = 'UI';
+const root = path.join(__dirname, '..', 'src');
+const isProduction = process.argv.indexOf('--production') > -1;
+
+console.log('ROOT:', root);
+console.log('isProduction:', isProduction);
+
+const cssVariables = [
+ '../src/Styles/Variables/colors',
+ '../src/Styles/Variables/dimensions',
+ '../src/Styles/Variables/fonts',
+ '../src/Styles/Variables/animations'
+].map(require.resolve);
+
+const config = {
+ devtool: '#source-map',
+ stats: {
+ children: false
+ },
+ watchOptions: {
+ ignored: /node_modules/
+ },
+ entry: {
+ preload: 'preload.js',
+ vendor: 'vendor.js',
+ index: 'index.js'
+ },
+ resolve: {
+ root: [
+ root,
+ path.join(root, 'Shims'),
+ path.join(root, 'JsLibraries')
+ ]
+ },
+ output: {
+ filename: path.join('_output', uiFolder, '[name].js'),
+ 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')
+ }
+ })
+ ],
+ resolveLoader: {
+ modulesDirectories: [
+ 'node_modules',
+ 'gulp/webpack/'
+ ]
+ },
+ eslint: {
+ formatter: function(results) {
+ return JSON.stringify(results);
+ }
+ },
+ module: {
+ loaders: [
+ {
+ test: /\.js?$/,
+ exclude: /(node_modules|JsLibraries)/,
+ loader: 'babel',
+ query: {
+ plugins: ['transform-class-properties'],
+ presets: ['es2015', 'decorators-legacy', 'react', 'stage-2'],
+ env: {
+ development: {
+ plugins: ['transform-react-jsx-source']
+ }
+ }
+ }
+ },
+
+ // CSS Modules
+ {
+ 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')
+ },
+
+ // Global styles
+ {
+ test: /\.css$/,
+ include: /(node_modules|globals.css)/,
+ loader: 'style!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]'
+ },
+ {
+ test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
+ loader: 'file-loader?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'
+ ]
+ })
+ ];
+ }
+};
+
+gulp.task('webpack', () => {
+ return gulp.src('index.js')
+ .pipe(webpackStream(config))
+ .pipe(gulp.dest(''));
+});
+
+gulp.task('webpackWatch', () => {
+ config.watch = true;
+ return gulp.src('')
+ .pipe(webpackStream(config))
+ .on('error', errorHandler)
+ .pipe(gulp.dest(''))
+ .on('error', errorHandler)
+ .pipe(livereload())
+ .on('error', errorHandler);
+});
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..8056639f6
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistConnector.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 createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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,
+ createCommandsSelector(),
+ (blacklist, commands) => {
+ const isClearingBlacklistExecuting = _.some(commands, { name: commandNames.CLEAR_BLACKLIST });
+
+ return {
+ isClearingBlacklistExecuting,
+ ...blacklist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...blacklistActions,
+ executeCommand
+};
+
+class BlacklistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.gotoBlacklistFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ //
+ // 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,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default 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..030dfe98a
--- /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;
+}
+
+.details {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js
new file mode 100644
index 000000000..50d71c30f
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.js
@@ -0,0 +1,175 @@
+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 EpisodeLanguage from 'Episode/EpisodeLanguage';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+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
+ } = 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 === 'details') {
+ return (
+
+
+
+ );
+ }
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+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
+};
+
+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..0cf173c9e
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import BlacklistRow from './BlacklistRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (series) => {
+ return {
+ series
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(BlacklistRow);
diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js
new file mode 100644
index 000000000..9c08c3895
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.js
@@ -0,0 +1,237 @@
+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';
+
+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/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..af2fb4f3f
--- /dev/null
+++ b/frontend/src/Activity/History/History.js
@@ -0,0 +1,195 @@
+import PropTypes from 'prop-types';
+import React, { Component } 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 MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import HistoryRowConnector from './HistoryRowConnector';
+
+class History extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ filterKey,
+ filterValue,
+ totalRecords,
+ isEpisodesFetching,
+ isEpisodesPopulated,
+ episodesError,
+ onFilterSelect,
+ onFirstPagePress,
+ ...otherProps
+ } = this.props;
+
+ const isFetchingAny = isFetching || isEpisodesFetching;
+ const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
+ const hasError = error || episodesError;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+ Grabbed
+
+
+
+ Imported
+
+
+
+ Failed
+
+
+
+ Deleted
+
+
+
+ Renamed
+
+
+
+
+
+
+
+ {
+ 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,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.string,
+ 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..9034dab84
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryConnector.js
@@ -0,0 +1,128 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+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() {
+ this.props.gotoHistoryFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const episodeIds = selectUniqueIds(this.props.items, 'episodeId');
+ this.props.fetchEpisodes({ episodeIds });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearHistory();
+ this.props.clearEpisodes();
+ }
+
+ //
+ // 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 = (filterKey, filterValue) => {
+ this.props.setHistoryFilter({ filterKey, filterValue });
+ }
+
+ 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 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..086354783
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryEventTypeCell.css
@@ -0,0 +1,3 @@
+.cell {
+ width: 35px;
+}
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..905bb5ffa
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRow.js
@@ -0,0 +1,254 @@
+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 ArtistNameLink from 'Artist/ArtistNameLink';
+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,
+ quality,
+ 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 (
+
+
+
+ );
+ }
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+HistoryRow.propTypes = {
+ episodeId: PropTypes.number,
+ series: PropTypes.object.isRequired,
+ episode: PropTypes.object,
+ language: PropTypes.object.isRequired,
+ quality: PropTypes.object.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..7ffcdbb57
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRowConnector.js
@@ -0,0 +1,69 @@
+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 createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import HistoryRow from './HistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeSelector(),
+ createUISettingsSelector(),
+ (episode, uiSettings) => {
+ return {
+ episode,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHistory,
+ markAsFailed
+};
+
+class HistoryRowConnector extends Component {
+
+ 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..621e4ac37
--- /dev/null
+++ b/frontend/src/Activity/Queue/Queue.js
@@ -0,0 +1,243 @@
+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 episodeEntities from 'Episode/episodeEntities';
+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 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
+ };
+ }
+
+ 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,
+ isEpisodesPopulated,
+ columns,
+ totalRecords,
+ isGrabbing,
+ isRemoving,
+ isCheckForFinishedDownloadExecuting,
+ onRefreshPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmRemoveModalOpen,
+ isPendingSelected
+ } = this.state;
+
+ const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting;
+ const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
+ const selectedCount = this.getSelectedIds().length;
+ const disableSelectedActions = selectedCount === 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isRefreshing && !isAllPopulated &&
+
+ }
+
+ {
+ !isRefreshing && error &&
+
+ Failed to load Queue
+
+ }
+
+ {
+ isAllPopulated && !error && !items.length &&
+
+ Queue is empty
+
+ }
+
+ {
+ isAllPopulated && !error && !!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,
+ isEpisodesPopulated: PropTypes.bool.isRequired,
+ 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..0828a0246
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueConnector.js
@@ -0,0 +1,153 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as queueActions from 'Store/Actions/queueActions';
+import { clearEpisodes } from 'Store/Actions/episodeActions';
+import * as commandNames from 'Commands/commandNames';
+import Queue from './Queue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.queue.paged,
+ (state) => state.queue.queueEpisodes,
+ createCommandsSelector(),
+ (queue, queueEpisodes, commands) => {
+ const isCheckForFinishedDownloadExecuting = _.some(commands, { name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD });
+
+ return {
+ isCheckForFinishedDownloadExecuting,
+ isEpisodesPopulated: queueEpisodes.isPopulated,
+ ...queue
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...queueActions,
+ clearEpisodes,
+ executeCommand
+};
+
+class QueueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.gotoQueueFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const episodes = _.uniqBy(_.reduce(this.props.items, (result, item) => {
+ result.push(item.episode);
+
+ return result;
+ }, []), ({ id }) => id);
+
+ this.props.clearEpisodes();
+ this.props.setQueueEpisodes({ episodes });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearQueue();
+ this.props.clearEpisodes();
+ }
+
+ //
+ // 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,
+ setQueueEpisodes: PropTypes.func.isRequired,
+ grabQueueItems: PropTypes.func.isRequired,
+ removeQueueItems: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default 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/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..54b0f4276
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.js
@@ -0,0 +1,348 @@
+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 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 ArtistNameLink from 'Artist/ArtistNameLink';
+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,
+ episodeEntity,
+ 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 (
+
+
+
+ );
+ }
+
+ if (name === 'series') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episode') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeTitle') {
+ 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 &&
+
+ }
+
+
+
+ );
+ }
+ })
+ }
+
+
+
+
+
+ );
+ }
+
+}
+
+QueueRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ downloadId: PropTypes.string,
+ episodeEntity: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ status: PropTypes.string.isRequired,
+ trackedDownloadStatus: PropTypes.string,
+ statusMessages: PropTypes.arrayOf(PropTypes.object),
+ errorMessage: PropTypes.string.isRequired,
+ series: PropTypes.object.isRequired,
+ episode: PropTypes.object.isRequired,
+ 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..0da6a1abc
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRowConnector.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 { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import QueueRow from './QueueRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ 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() {
+ if (!this.props.episode) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+QueueRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ episodeEntity: PropTypes.string.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..c8419a8f8
--- /dev/null
+++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.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 { fetchQueueStatus } from 'Store/Actions/queueActions';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ (state) => state.queue.queueStatus,
+ (app, status) => {
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: status.isPopulated,
+ ...status.item
+ };
+ }
+ );
+}
+
+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/Activity/activity.less b/frontend/src/Activity/activity.less
new file mode 100644
index 000000000..c6d9b6d2a
--- /dev/null
+++ b/frontend/src/Activity/activity.less
@@ -0,0 +1,27 @@
+
+.queue-status-cell .popover {
+ max-width: 800px;
+}
+
+.queue {
+ .protocol-cell {
+ text-align: center;
+ width: 80px;
+ }
+
+ .episode-number-cell {
+ min-width: 90px;
+ }
+}
+
+.remove-from-queue-modal {
+ .form-horizontal {
+ margin-top: 20px;
+ }
+}
+
+.history-detail-modal {
+ .info {
+ word-wrap: break-word;
+ }
+}
diff --git a/frontend/src/AddArtist/AddNewSeries/AddNewSeries.css b/frontend/src/AddArtist/AddNewSeries/AddNewSeries.css
new file mode 100644
index 000000000..c1ec4fbe3
--- /dev/null
+++ b/frontend/src/AddArtist/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: text 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/AddArtist/AddNewSeries/AddNewSeries.js b/frontend/src/AddArtist/AddNewSeries/AddNewSeries.js
new file mode 100644
index 000000000..c027dc906
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewSeries/AddNewSeries.js
@@ -0,0 +1,184 @@
+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 MusicBrainz ID of a show. eg. lidarr:71663
+
+
+ Why can't I find my artist?
+
+
+
+ }
+
+ {
+ !term &&
+
+
It's easy to add a new artist, just start typing the name the artist you want to add.
+
You can also search using MusicBrainz ID of a show. eg. lidarr: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/AddArtist/AddNewSeries/AddNewSeriesConnector.js b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesConnector.js
new file mode 100644
index 000000000..491bfe6f9
--- /dev/null
+++ b/frontend/src/AddArtist/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 queryString from 'query-string';
+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 query = queryString.parse(location.search);
+
+ return {
+ term: query.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/AddArtist/AddNewSeries/AddNewSeriesModal.js b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesModal.js
new file mode 100644
index 000000000..cb603e7a6
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/AddNewSeries/AddNewSeriesModalContent.css b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesModalContent.css
new file mode 100644
index 000000000..90526c529
--- /dev/null
+++ b/frontend/src/AddArtist/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 {
+ composes: button from 'Components/Link/SpinnerButton.css';
+ composes: truncate from 'Styles/mixins/truncate.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/AddArtist/AddNewSeries/AddNewSeriesModalContent.js b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesModalContent.js
new file mode 100644
index 000000000..3c42627e1
--- /dev/null
+++ b/frontend/src/AddArtist/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 ArtistPoster from 'Artist/ArtistPoster';
+import SeriesMonitoringOptionsPopoverContent from 'AddArtist/SeriesMonitoringOptionsPopoverContent';
+import SeriesTypePopoverContent from 'AddArtist/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 {
+ artistName,
+ year,
+ overview,
+ images,
+ isAdding,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ albumFolder,
+ tags,
+ showLanguageProfile,
+ isSmallScreen,
+ onModalClose,
+ onInputChange
+ } = this.props;
+
+ return (
+
+
+ {artistName}
+
+ {
+ !name.contains(year) &&
+ ({year})
+ }
+
+
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+
+
+
+
+
+
+ Start search for missing episodes
+
+
+
+
+
+
+ Add {artistName}
+
+
+
+ );
+ }
+}
+
+AddNewSeriesModalContent.propTypes = {
+ artistName: 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,
+ albumFolder: 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/AddArtist/AddNewSeries/AddNewSeriesModalContentConnector.js b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesModalContentConnector.js
new file mode 100644
index 000000000..d9fa91776
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesModalContentConnector.js
@@ -0,0 +1,105 @@
+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.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 {
+ foreignArtistId,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ albumFolder,
+ tags
+ } = this.props;
+
+ this.props.addSeries({
+ foreignArtistId,
+ rootFolderPath: rootFolderPath.value,
+ monitor: monitor.value,
+ qualityProfileId: qualityProfileId.value,
+ languageProfileId: languageProfileId.value,
+ albumFolder: albumFolder.value,
+ tags: tags.value,
+ searchForMissingEpisodes
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNewSeriesModalContentConnector.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ languageProfileId: PropTypes.object,
+ albumFolder: 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/AddArtist/AddNewSeries/AddNewSeriesSearchResult.css b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesSearchResult.css
new file mode 100644
index 000000000..38ccffb4d
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/AddNewSeries/AddNewSeriesSearchResult.js b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesSearchResult.js
new file mode 100644
index 000000000..428a1a5fb
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesSearchResult.js
@@ -0,0 +1,169 @@
+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 ArtistPoster from 'Artist/ArtistPoster';
+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 {
+ foreignArtistId,
+ artistName,
+ nameSlug,
+ year,
+ network,
+ status,
+ overview,
+ seasonCount,
+ ratings,
+ images,
+ isExistingSeries,
+ isSmallScreen
+ } = this.props;
+
+ const linkProps = isExistingSeries ? { to: `/series/${nameSlug}` } : { onPress: this.onPress };
+ let seasons = '1 Season';
+
+ if (seasonCount > 1) {
+ seasons = `${seasonCount} Seasons`;
+ }
+
+ return (
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+
+ {artistName}
+
+ {
+ !name.contains(year) && !!year &&
+ ({year})
+ }
+
+ {
+ isExistingSeries &&
+
+ }
+
+
+
+
+
+
+
+ {
+ !!network &&
+
+ {network}
+
+ }
+
+ {
+ !!seasonCount &&
+
+ {seasons}
+
+ }
+
+ {
+ status === 'ended' &&
+
+ Ended
+
+ }
+
+
+
+ {overview}
+
+
+
+
+
+ );
+ }
+}
+
+AddNewSeriesSearchResult.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ year: PropTypes.number,
+ network: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ seasonCount: PropTypes.number,
+ 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/AddArtist/AddNewSeries/AddNewSeriesSearchResultConnector.js b/frontend/src/AddArtist/AddNewSeries/AddNewSeriesSearchResultConnector.js
new file mode 100644
index 000000000..5ba942270
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/Import/ImportSeries.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeries.js
new file mode 100644
index 000000000..0f0e2ce1f
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/Import/ImportSeriesConnector.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesConnector.js
new file mode 100644
index 000000000..55f153681
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesConnector.js
@@ -0,0 +1,121 @@
+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.languageProfiles,
+ (match, rootFolders, addSeries, importSeriesState, languageProfiles) => {
+ const {
+ isFetching: rootFoldersFetching,
+ isPopulated: rootFoldersPopulated,
+ error: rootFoldersError,
+ items
+ } = rootFolders;
+
+ const rootFolderId = parseInt(match.params.rootFolderId);
+
+ const result = {
+ rootFolderId,
+ rootFoldersFetching,
+ rootFoldersPopulated,
+ rootFoldersError,
+ showLanguageProfile: languageProfiles.items.length > 1
+ };
+
+ if (items.length) {
+ const rootFolder = _.find(items, { id: rootFolderId });
+
+ return {
+ ...result,
+ ...rootFolder,
+ items: importSeriesState.items
+ };
+ }
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setImportSeriesValue,
+ importSeries,
+ clearImportSeries,
+ fetchRootFolders,
+ setAddSeriesDefault
+};
+
+class ImportSeriesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.rootFoldersPopulated) {
+ this.props.fetchRootFolders();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearImportSeries();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (ids, name, value) => {
+ this.props.setAddSeriesDefault({ [name]: value });
+
+ ids.forEach((id) => {
+ this.props.setImportSeriesValue({
+ id,
+ [name]: value
+ });
+ });
+ }
+
+ onImportPress = (ids) => {
+ this.props.importSeries({ ids });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+const routeMatchShape = createRouteMatchShape({
+ rootFolderId: PropTypes.string.isRequired
+});
+
+ImportSeriesConnector.propTypes = {
+ match: routeMatchShape.isRequired,
+ rootFoldersPopulated: PropTypes.bool.isRequired,
+ setImportSeriesValue: PropTypes.func.isRequired,
+ importSeries: PropTypes.func.isRequired,
+ clearImportSeries: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired,
+ setAddSeriesDefault: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);
diff --git a/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooter.css b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooter.css
new file mode 100644
index 000000000..1df1b8c90
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooter.css
@@ -0,0 +1,27 @@
+.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;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin: 0 10px 0 12px;
+ text-align: left;
+}
diff --git a/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooter.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooter.js
new file mode 100644
index 000000000..98c4c7ff2
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooter.js
@@ -0,0 +1,263 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+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,
+ showLanguageProfile,
+ onImportPress
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder
+ } = this.state;
+
+ return (
+
+
+
+
+
+ Quality Profile
+
+
+
+
+
+ {
+ showLanguageProfile &&
+
+
+
+ Language Profile
+
+
+
+
+ }
+
+
+
+
+
+ Season Folder
+
+
+
+
+
+
+
+
+
+
+
+
+ Import {selectedCount} Series
+
+
+ {
+ 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,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired
+};
+
+export default ImportSeriesFooter;
diff --git a/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooterConnector.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooterConnector.js
new file mode 100644
index 000000000..00fb7835a
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesFooterConnector.js
@@ -0,0 +1,57 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+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 items = importSeries.items;
+
+ const isLookingUpSeries = _.some(importSeries.items, (series) => {
+ return !series.isPopulated && series.error == null;
+ });
+
+ 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');
+
+ return {
+ selectedCount: selectedIds.length,
+ isImporting: importSeries.isImporting,
+ isLookingUpSeries,
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultSeriesType,
+ defaultSeasonFolder,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isLanguageProfileIdMixed,
+ isSeriesTypeMixed,
+ isSeasonFolderMixed
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ImportSeriesFooter);
diff --git a/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesHeader.css b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesHeader.css
new file mode 100644
index 000000000..36a57ea73
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/Import/ImportSeriesHeader.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesHeader.js
new file mode 100644
index 000000000..a01343f97
--- /dev/null
+++ b/frontend/src/AddArtist/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 'AddArtist/SeriesMonitoringOptionsPopoverContent';
+import SeriesTypePopoverContent from 'AddArtist/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/AddArtist/ImportSeries/Import/ImportSeriesRow.css b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesRow.css
new file mode 100644
index 000000000..10329ea1c
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/Import/ImportSeriesRow.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesRow.js
new file mode 100644
index 000000000..f8733d174
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesRow.js
@@ -0,0 +1,121 @@
+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,
+ queued: PropTypes.bool.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/AddArtist/ImportSeries/Import/ImportSeriesRowConnector.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesRowConnector.js
new file mode 100644
index 000000000..cdc5bac4b
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesRowConnector.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 { queueLookupSeries, 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 = {
+ queueLookupSeries,
+ 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),
+ queueLookupSeries: PropTypes.func.isRequired,
+ setImportSeriesValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);
diff --git a/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesSelected.css b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesSelected.css
new file mode 100644
index 000000000..efc6dccb3
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesSelected.css
@@ -0,0 +1,3 @@
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+}
diff --git a/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesTable.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesTable.js
new file mode 100644
index 000000000..7db302686
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesTable.js
@@ -0,0 +1,213 @@
+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
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._table = null;
+ }
+
+ 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;
+ }
+ });
+
+ // Forces the table to re-render if the selected state
+ // has changed otherwise it will be stale.
+
+ if (prevProps.selectedState !== selectedState && this._table) {
+ this._table.forceUpdateGrid();
+ }
+ }
+
+ //
+ // Control
+
+ setTableRef = (ref) => {
+ this._table = ref;
+ }
+
+ 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,
+ onSelectAllChange,
+ onScroll
+ } = this.props;
+
+ if (!items.length) {
+ return null;
+ }
+
+ return (
+
+ }
+ 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/AddArtist/ImportSeries/Import/ImportSeriesTableConnector.js b/frontend/src/AddArtist/ImportSeries/Import/ImportSeriesTableConnector.js
new file mode 100644
index 000000000..a09d5fa80
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css
new file mode 100644
index 000000000..83aa37175
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css
@@ -0,0 +1,8 @@
+.series {
+ padding: 10px 20px;
+ width: 100%;
+
+ &:hover {
+ background-color: $menuItemHoverColor;
+ }
+}
diff --git a/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js
new file mode 100644
index 000000000..d82cdc924
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js
new file mode 100644
index 000000000..81bb3059b
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css
new file mode 100644
index 000000000..be0acafca
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css
@@ -0,0 +1,72 @@
+.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 {
+ position: absolute;
+ right: 16px;
+}
+
+.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: text from 'Components/Form/TextInput.css';
+
+ /*border-left: 0;*/
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
diff --git a/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js
new file mode 100644
index 000000000..df095a79b
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js
@@ -0,0 +1,268 @@
+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 SpinnerIcon from 'Components/SpinnerIcon';
+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);
+ });
+ }
+
+ onSeriesSelect = (tvdbId) => {
+ this.setState({ isOpen: false });
+
+ this.props.onSeriesSelect(tvdbId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedSeries,
+ isExistingSeries,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ queued,
+ onSeriesSelect
+ } = this.props;
+
+ const errorMessage = error &&
+ error.responseJSON &&
+ error.responseJSON.message;
+
+ return (
+
+
+ {
+ queued && !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,
+ queued: PropTypes.bool.isRequired,
+ onSearchInputChange: PropTypes.func.isRequired,
+ onSeriesSelect: PropTypes.func.isRequired
+};
+
+ImportSeriesSelectSeries.defaultProps = {
+ isFetching: true,
+ isPopulated: false,
+ items: [],
+ queued: true
+};
+
+export default ImportSeriesSelectSeries;
diff --git a/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js
new file mode 100644
index 000000000..ac4ac79b5
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.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 { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
+import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
+import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
+
+function createMapStateToProps() {
+ return createSelector(
+ createImportSeriesItemSelector(),
+ (item) => {
+ return item;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ queueLookupSeries,
+ setImportSeriesValue
+};
+
+class ImportSeriesSelectSeriesConnector extends Component {
+
+ //
+ // Listeners
+
+ onSearchInputChange = (term) => {
+ this.props.queueLookupSeries({
+ name: this.props.id,
+ term
+ });
+ }
+
+ 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/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css
new file mode 100644
index 000000000..f6ae0f4e6
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css
@@ -0,0 +1,17 @@
+.titleContainer {
+ display: flex;
+ align-items: center;
+}
+
+.title {
+ margin-right: 5px;
+}
+
+.year {
+ margin-left: 5px;
+ color: $disabledColor;
+}
+
+.existing {
+ margin-left: 5px;
+}
diff --git a/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js
new file mode 100644
index 000000000..3cb6a55dc
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js
@@ -0,0 +1,50 @@
+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})
+ }
+
+
+ {
+ !!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/AddArtist/ImportSeries/ImportSeries.js b/frontend/src/AddArtist/ImportSeries/ImportSeries.js
new file mode 100644
index 000000000..0ee3eaf47
--- /dev/null
+++ b/frontend/src/AddArtist/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 'AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
+import ImportSeriesConnector from 'AddArtist/ImportSeries/Import/ImportSeriesConnector';
+
+class ImportSeries extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default ImportSeries;
diff --git a/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css
new file mode 100644
index 000000000..d9c5ccb01
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js
new file mode 100644
index 000000000..3ba5669e3
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js
@@ -0,0 +1,64 @@
+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/AddArtist/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js
new file mode 100644
index 000000000..f0fb03921
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css
new file mode 100644
index 000000000..030da96fb
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js
new file mode 100644
index 000000000..798a0c940
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js
@@ -0,0 +1,188 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { 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 {
+ 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. "\tv shows\" and not "\tv shows\the simpsons\"
+
+
+
+
+ {
+ items.length > 0 ?
+
+
+
+
+ {
+ items.map((rootFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+ Choose another folder
+
+
:
+
+
+
+
+ Start Import
+
+
+ }
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ImportSeriesSelectFolder.propTypes = {
+ 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/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
new file mode 100644
index 000000000..b9f82e376
--- /dev/null
+++ b/frontend/src/AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.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 { push } from 'react-router-redux';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions';
+import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ (rootFolders) => {
+ return rootFolders;
+ }
+ );
+}
+
+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/AddArtist/SeriesMonitoringOptionsPopoverContent.js b/frontend/src/AddArtist/SeriesMonitoringOptionsPopoverContent.js
new file mode 100644
index 000000000..dcd5a4da5
--- /dev/null
+++ b/frontend/src/AddArtist/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/AddArtist/SeriesTypePopoverContent.js b/frontend/src/AddArtist/SeriesTypePopoverContent.js
new file mode 100644
index 000000000..e57d49a9e
--- /dev/null
+++ b/frontend/src/AddArtist/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..bf5920ebf
--- /dev/null
+++ b/frontend/src/App/App.js
@@ -0,0 +1,245 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import { Provider } from 'react-redux';
+import { Route, Redirect } from 'react-router-dom';
+import { ConnectedRouter } from 'react-router-redux';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import NotFound from 'Components/NotFound';
+import Switch from 'Components/Router/Switch';
+import PageConnector from 'Components/Page/PageConnector';
+import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
+import AddNewSeriesConnector from 'AddArtist/AddNewSeries/AddNewSeriesConnector';
+import ImportSeries from 'AddArtist/ImportSeries/ImportSeries';
+import SeriesEditorConnector from 'Artist/Editor/SeriesEditorConnector';
+import SeasonPassConnector from 'SeasonPass/SeasonPassConnector';
+import SeriesDetailsPageConnector from 'Artist/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 UISettingsConnector from 'Settings/UI/UISettingsConnector';
+import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
+import Profiles from 'Settings/Profiles/Profiles';
+import Quality from 'Settings/Quality/Quality';
+import IndexerSettings from 'Settings/Indexers/IndexerSettings';
+import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
+import NotificationSettings from 'Settings/Notifications/NotificationSettings';
+import MetadataSettings from 'Settings/Metadata/MetadataSettings';
+import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
+import Status from 'System/Status/Status';
+import TasksConnector from 'System/Tasks/TasksConnector';
+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 App({ store, history }) {
+ return (
+
+
+
+
+
+ {/*
+ Series
+ */}
+
+
+
+ {
+ window.Sonarr.urlBase &&
+ {
+ return (
+
+ );
+ }}
+ />
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Calendar
+ */}
+
+
+
+ {/*
+ Activity
+ */}
+
+
+
+
+
+
+
+ {/*
+ Wanted
+ */}
+
+
+
+
+
+ {/*
+ Settings
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ System
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not Found
+ */}
+
+
+
+
+
+
+
+
+ );
+}
+
+App.propTypes = {
+ store: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired
+};
+
+export default App;
diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js
new file mode 100644
index 000000000..fe48e67f4
--- /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,
+ version: PropTypes.string.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..bfa0bde38
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppUpdatedModal from './AppUpdatedModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.version,
+ (version) => {
+ return {
+ version
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModal);
diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css
new file mode 100644
index 000000000..459ddafc0
--- /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;
+ font-size: 18px;
+ border-bottom: 1px solid #e5e5e5;
+}
diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js
new file mode 100644
index 000000000..123267501
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+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 = {
+ isOpen: PropTypes.bool.isRequired,
+ version: PropTypes.string.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object.isRequired,
+ 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..7acd56f41
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContentConnector.js
@@ -0,0 +1,69 @@
+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
+
+ componentDidUpdate() {
+ this.props.dispatchFetchUpdates();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchUpdates,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+AppUpdatedModalContentConnector.propTypes = {
+ 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/Artist/ArtistNameLink.js b/frontend/src/Artist/ArtistNameLink.js
new file mode 100644
index 000000000..506881cac
--- /dev/null
+++ b/frontend/src/Artist/ArtistNameLink.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+
+function ArtistNameLink({ nameSlug, artistName }) {
+ const link = `/series/${nameSlug}`;
+
+ return (
+
+ {artistName}
+
+ );
+}
+
+ArtistNameLink.propTypes = {
+ nameSlug: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired
+};
+
+export default ArtistNameLink;
diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js
new file mode 100644
index 000000000..80bc979f9
--- /dev/null
+++ b/frontend/src/Artist/ArtistPoster.js
@@ -0,0 +1,160 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3AgMAAAD0/fcFAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAGWklEQVRo3u2Zv2/TQBTHj4eQnKsY2S1QJHDE2J2RkQHHUVWVjIipY5QBnTpV3bNblSq5RqFTFyQE/4T3iiliZ2GgvLt3d8/ncxNEV7+lbvvJ87vvu1/vRQw22GCDDTbYYIPFJutvf+ryX8hPubGfO0G4yq39Vv/i85/8Zk3OVm0j4Tpv2dG2EC7zwD5scepeb38Wd7t96dyt7M/FTqeFQwu1y+kRotvdeuDAP037yb3c2dKj+U0vOmaRGJ31oj5Rt3V9656LPnKUk72r6/O6rn/ZX+f97yeygnMlPDu7+/0/SyFPUE75iSPoH/+yFISKbM0a9Olf1BoCkyQbwuIO/ZcaqgiFq/4sAI2pFIyKjNyqXql+m+fSouKqV66xj1RAKRFVfvnM+kL9aN4vvVf42hMsNLxGpXKomPRM2ofmbyWNRjg0lcbD9wB9bKYpEc8ZFRc52nE8qg09Vy30UzyuFb9flC1UNtG4WjsEILrSWvAeEo1qafyAVIjmRYqPkIh1d1wjGyrg8xeUa4XvAJZr3h4VhzpC57Dy/5dNZ1z7FKr22mwsWnCwh4EAFCqgwJVGWc61l4Bn4Hv6UFES6oAXfh6yACUtm6ki1EUrQwlGZlJQ0EeMHvqJNA9mwNEpLdul8GiRunEdB1otE3x4kDPqnK2tWqzVIqHPbBgl4qUNhbW6SeihZJQ0mLgHF3lRPjWiFRUIcaJREikVZ01rIYBL68hOL3Do3EnAqEUeddE3JAGjI42amEkARt/q3zQ6Z5QG23TRgqQk1MmxsPndMEoZTey/OQPvrfcqROd2wsxa6A3ltygdyuPaMyhnoMQ37WsBUkYtIvWoGS0Uok80Gnp9a4WdMjpFlAWov/1qSQArj0JjUXcDwKPoqrGsRQvFyQKRkFaY9Brt2y3ZmUhNDhg9MJOV5lUdWikuCLXKL1kr6KDK5OC7Rxc8WTLnjEy20ZFN1ph2WOfNoZSueYDu6zgiNAtRyusToxVwjMYqyuzcL+0fAjRaKSEdWqHSaOeE0vJ+ZEVqjAAZo3ZjFpLmDaMJ5qyEAE2F2evPPDq2KGnFghpUKvI68yj94UBEaEbpZLQgdCkAGFUaneB/TpoO+tAcnAGKtsY0QQ+6Qd8hChd96APUCiL0+nUHnRKqAlRrLVevaG4Tuu9Q1CpCG4sehuhBjE7yGFWILjsBpEJN8tci7XpNH6BWSYAq1Cr2WgLOxQ0o2UIr9HeRv0rFSTfWEc6rMFZEr0OvY4tOe9CG0E4KRrRhB3NAajTO1t5BjE5wqXanS4lzYCm6qCA08gqbGMWTUHXQXNHahHBq6y3o1E9tXjCgBWijdA6J9oJ5xCjQiiWj647oWbFShWhl7jhFwihtGTcKZCoUqESy1yTDHT4RKW8ZIzquMmHsrCXApdnZYdLd3ujEEmlLgDWdG5cxSsYoXNDIaSfkrRgcKv0agGsazjrcteN9nS55c4/ysSE7KJBIryhneeswEtl5fUv2x22ZE+2N7w58xAnhTsDfiLoCWPERxwcnl3Hv/iBJMRYi4YMTzX3qCZH0QXrxNDqOaYcfRygf8uHVIXncRhVorY4xgEnPLWMUes16LiQv7SH7zKO4rq1WUqTta87IoAlJwF7XweWJ0SONPumifCULLnqIPmKUJ4tNFqOFRkddlK+PwaUUVwY49NRqpdBB5i6lfNUVSj+w18zmaC8oDPatBGrsULNh5zMFIgkv0GODoj1wKAnwKr6WPzYS2KgZLYTT6ri3hCDUCHDoS6N5XJjAnkfxALjhwqSv3GkcKpsiLne4iKKwCc3yBRdRcWlGbgmdoNOUS7O+gu+FRddzRF3BF5eRJA6hnwWiXEbGxSmFgCgoQqk4jUre0pX6iEoVlLxRIZ0mCJwigGiZqERwIR2V5+Avopniind2Z9FvvD6nB1f0x60ERmm3jVoJfkptwKMlNxgL1df2UA4FFbY94mZK5VAcWNxMiVs0jPa3aMA2syyamUFx46e3nUSo1grWdzT1XrJbRHXQNS20xZbWlwI4LRXIqPUVNdTQIZwobqhta9PVCs7KjNt0W5t/9VnQ/LtPS5EiIH0udzQq9xhd72h/ipVHL/zTrlbt9HpXqxYahzbcAN7hdrrLKbst2On9W+BxY/3+7fr4S4D/+Grh/l9Y8Ncggw022GCDDTbYYPexvyOoQXprv7w6AAAAAElFTkSuQmCC';
+
+function findPoster(images) {
+ return _.find(images, { coverType: 'poster' });
+}
+
+function getPosterUrl(poster, size) {
+ if (poster) {
+ // Remove protocol
+ let url = poster.url.replace(/^https?:/, '');
+ url = url.replace('poster.jpg', `poster-${size}.jpg`);
+
+ return url;
+ }
+}
+
+class ArtistPoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.floor(window.devicePixelRatio);
+
+ const {
+ images,
+ size
+ } = props;
+
+ const poster = findPoster(images);
+
+ this.state = {
+ pixelRatio,
+ poster,
+ posterUrl: getPosterUrl(poster, pixelRatio * size),
+ hasError: false,
+ isLoaded: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ images,
+ size
+ } = this.props;
+
+ const {
+ pixelRatio
+ } = this.state;
+
+ const poster = findPoster(images);
+
+ if (poster && poster.url !== this.state.poster.url) {
+ this.setState({
+ poster,
+ posterUrl: getPosterUrl(poster, pixelRatio * size),
+ hasError: false,
+ isLoaded: false
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({ hasError: true });
+ }
+
+ onLoad = () => {
+ this.setState({ isLoaded: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ posterUrl,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !posterUrl) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistPoster.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ size: PropTypes.number.isRequired,
+ lazy: PropTypes.bool.isRequired,
+ overflow: PropTypes.bool.isRequired
+};
+
+ArtistPoster.defaultProps = {
+ size: 250,
+ lazy: true,
+ overflow: false
+};
+
+export default ArtistPoster;
diff --git a/frontend/src/Artist/Delete/DeleteArtist.less b/frontend/src/Artist/Delete/DeleteArtist.less
new file mode 100644
index 000000000..72670081b
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtist.less
@@ -0,0 +1,39 @@
+@import "Content/icons";
+
+.delete-series-modal {
+
+ i {
+ margin-right : 5px;
+ //.fa-icon-color(white);
+
+ }
+
+ .path {
+ white-space : nowrap;
+ font-size : 16px;
+ padding-bottom : 20px;
+ }
+
+ .delete-files-info,
+ .delete-label {
+ color : @brand-danger-dark;
+ }
+
+ .delete-files-info {
+ display : none;
+ }
+
+ .checkbox {
+ display : inline-block;
+ }
+
+ .c-checkbox:hover .check {
+ border-color : @brand-danger-dark;
+ }
+
+ input[type=checkbox]:checked + span {
+ background-color : @brand-danger-dark;
+ border-color : @brand-danger-dark;
+ }
+
+}
\ No newline at end of file
diff --git a/frontend/src/Artist/Delete/DeleteArtistModal.js b/frontend/src/Artist/Delete/DeleteArtistModal.js
new file mode 100644
index 000000000..5b6490c66
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModal.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 DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
+
+function DeleteArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModal;
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Delete/DeleteArtistModalContent.css
new file mode 100644
index 000000000..dbfef0871
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.css
@@ -0,0 +1,12 @@
+.pathContainer {
+ margin-bottom: 20px;
+}
+
+.pathIcon {
+ margin-right: 8px;
+}
+
+.deleteFilesMessage {
+ margin-top: 20px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js
new file mode 100644
index 000000000..d30a589de
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js
@@ -0,0 +1,139 @@
+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 './DeleteArtistModalContent.css';
+
+class DeleteArtistModalContent 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 {
+ artistName,
+ path,
+ trackFileCount,
+ sizeOnDisk,
+ onModalClose
+ } = this.props;
+
+ const deleteFiles = this.state.deleteFiles;
+ let deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
+ let deleteFilesHelpText = 'Delete the track files and artist folder';
+
+ if (trackFileCount === 0) {
+ deleteFilesLabel = 'Delete Artist Folder';
+ deleteFilesHelpText = 'Delete the artist folder and it\'s contents';
+ }
+
+ return (
+
+
+ Delete - {artistName}
+
+
+
+
+
+
+ {path}
+
+
+
+ {deleteFilesLabel}
+
+
+
+
+ {
+ deleteFiles &&
+
+
The artist folder {path} and all it's content will be deleted.
+
+ {
+ !!trackFileCount &&
+
{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}
+ }
+
+ }
+
+
+
+
+
+ Close
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteArtistModalContent.propTypes = {
+ artistName: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ trackFileCount: PropTypes.number.isRequired,
+ sizeOnDisk: PropTypes.number.isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+DeleteArtistModalContent.defaultProps = {
+ trackFileCount: 0
+};
+
+export default DeleteArtistModalContent;
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js
new file mode 100644
index 000000000..4790780e7
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { deleteArtist } from 'Store/Actions/seriesActions';
+import DeleteArtistModalContent from './DeleteArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (series) => {
+ return series;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteArtist
+};
+
+class DeleteArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = (deleteFiles) => {
+ this.props.deleteArtist({
+ id: this.props.artistId,
+ deleteFiles
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DeleteArtistModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ deleteArtist: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector);
diff --git a/frontend/src/Artist/Details/EpisodeRow.css b/frontend/src/Artist/Details/EpisodeRow.css
new file mode 100644
index 000000000..fc7bf0397
--- /dev/null
+++ b/frontend/src/Artist/Details/EpisodeRow.css
@@ -0,0 +1,26 @@
+.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;
+}
+
+.language,
+.audio,
+.video,
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Artist/Details/EpisodeRow.js b/frontend/src/Artist/Details/EpisodeRow.js
new file mode 100644
index 000000000..5063db63b
--- /dev/null
+++ b/frontend/src/Artist/Details/EpisodeRow.js
@@ -0,0 +1,266 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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,
+ artistId,
+ episodeFileId,
+ monitored,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ airDateUtc,
+ title,
+ unverifiedSceneNumbering,
+ isSaving,
+ seriesMonitored,
+ seriesType,
+ episodeFilePath,
+ episodeFileRelativePath,
+ 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 === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+ }
+}
+
+EpisodeRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistId: 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,
+ mediaInfo: PropTypes.object,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMonitorEpisodePress: PropTypes.func.isRequired
+};
+
+export default EpisodeRow;
diff --git a/frontend/src/Artist/Details/EpisodeRowConnector.js b/frontend/src/Artist/Details/EpisodeRowConnector.js
new file mode 100644
index 000000000..e827c17b5
--- /dev/null
+++ b/frontend/src/Artist/Details/EpisodeRowConnector.js
@@ -0,0 +1,29 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import EpisodeRow from './EpisodeRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state, { sceneSeasonNumber }) => sceneSeasonNumber,
+ createArtistSelector(),
+ createEpisodeFileSelector(),
+ createCommandsSelector(),
+ (id, sceneSeasonNumber, series, episodeFile, commands) => {
+ const alternateTitles = sceneSeasonNumber ? _.filter(series.alternateTitles, { sceneSeasonNumber }) : [];
+
+ return {
+ seriesMonitored: series.monitored,
+ seriesType: series.seriesType,
+ episodeFilePath: episodeFile ? episodeFile.path : null,
+ episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null,
+ alternateTitles
+ };
+ }
+ );
+}
+export default connect(createMapStateToProps)(EpisodeRow);
diff --git a/frontend/src/Artist/Details/SeriesAlternateTitles.css b/frontend/src/Artist/Details/SeriesAlternateTitles.css
new file mode 100644
index 000000000..1af1ae68b
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesAlternateTitles.css
@@ -0,0 +1,3 @@
+.alternateTitle {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Artist/Details/SeriesAlternateTitles.js b/frontend/src/Artist/Details/SeriesAlternateTitles.js
new file mode 100644
index 000000000..18d016579
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Details/SeriesDetails.css b/frontend/src/Artist/Details/SeriesDetails.css
new file mode 100644
index 000000000..7a2daccb2
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesDetails.css
@@ -0,0 +1,119 @@
+.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 {
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.titleContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.title {
+ margin-bottom: 5px;
+ font-weight: 300;
+ font-size: 50px;
+ line-height: 50px;
+}
+
+.alternateTitlesIconContainer {
+ margin-left: 20px;
+ line-height: 50px;
+}
+
+.seriesNavigationButtons {
+ white-space: no-wrap;
+}
+
+.seriesNavigationButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ margin-left: 5px;
+ color: #e1e2e3;
+ white-space: nowrap;
+}
+
+.details {
+ font-weight: 300;
+ font-size: 20px;
+}
+
+.runtime {
+ margin-right: 15px;
+}
+
+.detailsLabel {
+ composes: label from 'Components/Label.css';
+
+ margin: 5px 10px 5px 0;
+}
+
+.sizeOnDisk,
+.qualityProfileName,
+.network,
+.links,
+.tags {
+ margin-left: 8px;
+ font-weight: 300;
+ font-size: 17px;
+}
+
+.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/Artist/Details/SeriesDetails.js b/frontend/src/Artist/Details/SeriesDetails.js
new file mode 100644
index 000000000..bdf1c2d53
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesDetails.js
@@ -0,0 +1,576 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+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 ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import SeriesAlternateTitles from './SeriesAlternateTitles';
+import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector';
+import SeriesTagsConnector from './SeriesTagsConnector';
+import SeriesDetailsLinks from './SeriesDetailsLinks';
+import styles from './SeriesDetails.css';
+
+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,
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false,
+ allExpanded: false,
+ allCollapsed: false,
+ expandedState: {}
+ };
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onManageEpisodesPress = () => {
+ this.setState({ isManageEpisodesOpen: true });
+ }
+
+ onManageEpisodesModalClose = () => {
+ this.setState({ isManageEpisodesOpen: false });
+ }
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: 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);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ tvdbId,
+ tvMazeId,
+ imdbId,
+ title,
+ runtime,
+ ratings,
+ sizeOnDisk,
+ episodeFileCount,
+ qualityProfileId,
+ monitored,
+ status,
+ network,
+ overview,
+ images,
+ seasons,
+ alternateTitles,
+ tags,
+ isRefreshing,
+ isSearching,
+ isFetching,
+ isPopulated,
+ episodesError,
+ episodeFilesError,
+ previousSeries,
+ nextSeries,
+ onRefreshPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ isOrganizeModalOpen,
+ isManageEpisodesOpen,
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen,
+ allExpanded,
+ allCollapsed,
+ expandedState
+ } = 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
+
+ }
+
+
+
+
+
+
+
+
+
+
+ {
+ 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}
+ />
+
+ }
+
+
+
+ {overview}
+
+
+
+
+
+
+ {
+ !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,
+ sizeOnDisk: PropTypes.number.isRequired,
+ episodeFileCount: PropTypes.number,
+ 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,
+ isRefreshing: PropTypes.bool.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ episodesError: PropTypes.object,
+ episodeFilesError: PropTypes.object,
+ previousSeries: PropTypes.object.isRequired,
+ nextSeries: PropTypes.object.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+export default SeriesDetails;
diff --git a/frontend/src/Artist/Details/SeriesDetailsConnector.js b/frontend/src/Artist/Details/SeriesDetailsConnector.js
new file mode 100644
index 000000000..e7892341a
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesDetailsConnector.js
@@ -0,0 +1,184 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand } from 'Utilities/Command';
+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 { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import SeriesDetails from './SeriesDetails';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { titleSlug }) => titleSlug,
+ (state) => state.episodes,
+ (state) => state.episodeFiles,
+ 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 previousSeries = sortedSeries[seriesIndex - 1] || _.last(sortedSeries);
+ const nextSeries = sortedSeries[seriesIndex + 1] || _.first(sortedSeries);
+ const isSeriesRefreshing = !!findCommand(commands, { name: commandNames.REFRESH_SERIES, artistId: series.id });
+ const allSeriesRefreshing = _.some(commands, (command) => command.name === commandNames.REFRESH_SERIES && !command.body.artistId);
+ const isRefreshing = isSeriesRefreshing || allSeriesRefreshing;
+ const isSearching = !!findCommand(commands, { name: commandNames.SERIES_SEARCH, artistId: series.id });
+ const isRenamingFiles = !!findCommand(commands, { name: commandNames.RENAME_FILES, artistId: series.id });
+ const isRenamingSeriesCommand = findCommand(commands, { name: commandNames.RENAME_SERIES });
+ const isRenamingSeries = !!(isRenamingSeriesCommand && isRenamingSeriesCommand.body.artistId.indexOf(series.id) > -1);
+
+ const isFetching = episodes.isFetching || episodeFiles.isFetching;
+ const isPopulated = episodes.isPopulated && episodeFiles.isPopulated;
+ const episodesError = episodes.error;
+ const episodeFilesError = episodeFiles.error;
+ 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,
+ isRefreshing,
+ isSearching,
+ isRenamingFiles,
+ isRenamingSeries,
+ isFetching,
+ isPopulated,
+ episodesError,
+ episodeFilesError,
+ previousSeries,
+ nextSeries
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchEpisodes,
+ clearEpisodes,
+ fetchEpisodeFiles,
+ clearEpisodeFiles,
+ fetchQueueDetails,
+ clearQueueDetails,
+ executeCommand
+};
+
+class SeriesDetailsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this._populate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ isRefreshing,
+ isRenamingFiles,
+ isRenamingSeries
+ } = this.props;
+
+ if (
+ (prevProps.isRefreshing && !isRefreshing) ||
+ (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() {
+ this._unpopulate();
+ }
+
+ //
+ // Control
+
+ _populate() {
+ const artistId = this.props.id;
+
+ this.props.fetchEpisodes({ artistId });
+ this.props.fetchEpisodeFiles({ artistId });
+ this.props.fetchQueueDetails({ artistId });
+ }
+
+ _unpopulate() {
+ this.props.clearEpisodes();
+ this.props.clearEpisodeFiles();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_SERIES,
+ artistId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.SERIES_SEARCH,
+ artistId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesDetailsConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ titleSlug: PropTypes.string.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,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsConnector);
diff --git a/frontend/src/Artist/Details/SeriesDetailsLinks.css b/frontend/src/Artist/Details/SeriesDetailsLinks.css
new file mode 100644
index 000000000..0f65b9154
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Details/SeriesDetailsLinks.js b/frontend/src/Artist/Details/SeriesDetailsLinks.js
new file mode 100644
index 000000000..9cacdb1a3
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Details/SeriesDetailsPageConnector.js b/frontend/src/Artist/Details/SeriesDetailsPageConnector.js
new file mode 100644
index 000000000..bf440a532
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Details/SeriesDetailsSeason.css b/frontend/src/Artist/Details/SeriesDetailsSeason.css
new file mode 100644
index 000000000..b6cffa8f1
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesDetailsSeason.css
@@ -0,0 +1,113 @@
+.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;
+}
+
+.episodeCountContainer {
+ margin-left: 10px;
+ vertical-align: text-bottom;
+}
+
+.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: 14px;
+}
+
+.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/Artist/Details/SeriesDetailsSeason.js b/frontend/src/Artist/Details/SeriesDetailsSeason.js
new file mode 100644
index 000000000..9b1e8c03b
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesDetailsSeason.js
@@ -0,0 +1,393 @@
+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 } 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 EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import EpisodeRowConnector from './EpisodeRowConnector';
+import styles from './SeriesDetailsSeason.css';
+
+function getSeasonStatistics(episodes) {
+ let episodeCount = 0;
+ let episodeFileCount = 0;
+ let totalEpisodeCount = 0;
+
+ episodes.forEach((episode) => {
+ if (episode.episodeFileId || (episode.monitored && isBefore(episode.airDateUtc))) {
+ episodeCount++;
+ }
+
+ if (episode.episodeFileId) {
+ episodeFileCount++;
+ }
+
+ totalEpisodeCount++;
+ });
+
+ return {
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount
+ };
+}
+
+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,
+ lastToggledEpisode: null
+ };
+ }
+
+ componentDidMount() {
+ this._expandByDefault();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.artistId !== this.props.artistId) {
+ this._expandByDefault();
+ }
+ }
+
+ //
+ // 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 });
+ }
+
+ 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 {
+ artistId,
+ monitored,
+ seasonNumber,
+ items,
+ columns,
+ isSaving,
+ isExpanded,
+ isSearching,
+ seriesMonitored,
+ isSmallScreen,
+ onTableOptionChange,
+ onMonitorSeasonPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ episodeCount,
+ episodeFileCount,
+ totalEpisodeCount
+ } = getSeasonStatistics(items);
+
+ const {
+ isOrganizeModalOpen,
+ isManageEpisodesOpen
+ } = this.state;
+
+ return (
+
+
+
+
+
+ {
+ seasonNumber === 0 ?
+
+ Specials
+ :
+
+ Season {seasonNumber}
+
+ }
+
+
+ {
+ {episodeFileCount} / {episodeCount}
+ }
+
+
+
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+ {
+ isSmallScreen ?
+
+
+
+
+
+
+
+
+
+ Search
+
+
+
+
+
+ Preview Rename
+
+
+
+
+
+ Manage Episodes
+
+
+ :
+
+
+
+
+
+
+
+
+ }
+
+
+
+
+ {
+ isExpanded &&
+
+ {
+ items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+
+
+ No episodes in this season
+
+ }
+
+
+
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesDetailsSeason.propTypes = {
+ artistId: 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/Artist/Details/SeriesDetailsSeasonConnector.js b/frontend/src/Artist/Details/SeriesDetailsSeasonConnector.js
new file mode 100644
index 000000000..71dfaeddd
--- /dev/null
+++ b/frontend/src/Artist/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 } from 'Utilities/Command';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+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,
+ createArtistSelector(),
+ createCommandsSelector(),
+ createDimensionsSelector(),
+ (seasonNumber, episodes, series, commands, dimensions) => {
+ const isSearching = !!findCommand(commands, {
+ name: commandNames.SEASON_SEARCH,
+ artistId: 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 {
+ artistId,
+ seasonNumber
+ } = this.props;
+
+ this.props.toggleSeasonMonitored({
+ artistId,
+ seasonNumber,
+ monitored
+ });
+ }
+
+ onSearchPress = () => {
+ const {
+ artistId,
+ seasonNumber
+ } = this.props;
+
+ this.props.executeCommand({
+ name: commandNames.SEASON_SEARCH,
+ artistId,
+ seasonNumber
+ });
+ }
+
+ onMonitorEpisodePress = (episodeIds, monitored) => {
+ this.props.toggleEpisodesMonitored({
+ episodeIds,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesDetailsSeasonConnector.propTypes = {
+ artistId: 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/Artist/Details/SeriesTags.css b/frontend/src/Artist/Details/SeriesTags.css
new file mode 100644
index 000000000..ec340a041
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesTags.css
@@ -0,0 +1,8 @@
+.tags {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.tag {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Artist/Details/SeriesTags.js b/frontend/src/Artist/Details/SeriesTags.js
new file mode 100644
index 000000000..4897937dd
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesTags.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+import styles from './SeriesTags.css';
+
+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/Artist/Details/SeriesTagsConnector.js b/frontend/src/Artist/Details/SeriesTagsConnector.js
new file mode 100644
index 000000000..354b2cec2
--- /dev/null
+++ b/frontend/src/Artist/Details/SeriesTagsConnector.js
@@ -0,0 +1,30 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import SeriesTags from './SeriesTags';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ 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/Artist/Edit/EditArtistModal.js b/frontend/src/Artist/Edit/EditArtistModal.js
new file mode 100644
index 000000000..6e99a2f53
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditArtistModalContentConnector from './EditArtistModalContentConnector';
+
+function EditArtistModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditArtistModal;
diff --git a/frontend/src/Artist/Edit/EditArtistModalConnector.js b/frontend/src/Artist/Edit/EditArtistModalConnector.js
new file mode 100644
index 000000000..83a3323bf
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalConnector.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 EditArtistModal from './EditArtistModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditArtistModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'series' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditArtistModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(EditArtistModalConnector);
diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.css b/frontend/src/Artist/Edit/EditArtistModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js
new file mode 100644
index 000000000..b8494c525
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContent.js
@@ -0,0 +1,165 @@
+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 styles from './EditArtistModalContent.css';
+
+class EditArtistModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ item,
+ isSaving,
+ showLanguageProfile,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteSeriesPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ monitored,
+ albumFolder,
+ qualityProfileId,
+ languageProfileId,
+ // seriesType,
+ path,
+ tags
+ } = item;
+
+ return (
+
+
+ Edit - {artistName}
+
+
+
+
+
+
+
+ Delete
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+EditArtistModalContent.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ item: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteSeriesPress: PropTypes.func.isRequired
+};
+
+export default EditArtistModalContent;
diff --git a/frontend/src/Artist/Edit/EditArtistModalContentConnector.js b/frontend/src/Artist/Edit/EditArtistModalContentConnector.js
new file mode 100644
index 000000000..9bd312233
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContentConnector.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 selectSettings from 'Store/Selectors/selectSettings';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { setSeriesValue, saveArtist } from 'Store/Actions/seriesActions';
+import EditArtistModalContent from './EditArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.series,
+ (state) => state.settings.languageProfiles,
+ createArtistSelector(),
+ (seriesState, languageProfiles, series) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges
+ } = seriesState;
+
+ const seriesSettings = _.pick(series, [
+ 'monitored',
+ 'albumFolder',
+ 'qualityProfileId',
+ 'languageProfileId',
+ // 'seriesType',
+ 'path',
+ 'tags'
+ ]);
+
+ const settings = selectSettings(seriesSettings, pendingChanges, saveError);
+
+ return {
+ artistName: series.artistName,
+ isSaving,
+ saveError,
+ pendingChanges,
+ item: settings.settings,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setSeriesValue,
+ saveArtist
+};
+
+class EditArtistModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setSeriesValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveArtist({ id: this.props.artistId });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditArtistModalContentConnector.propTypes = {
+ artistId: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ setSeriesValue: PropTypes.func.isRequired,
+ saveArtist: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditArtistModalContentConnector);
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js
new file mode 100644
index 000000000..11fd79d5d
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
+
+function DeleteArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModal;
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css
new file mode 100644
index 000000000..950fdc27d
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.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/Artist/Editor/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js
new file mode 100644
index 000000000..285804022
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.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 './DeleteArtistModalContent.css';
+
+class DeleteArtistModalContent 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
+
+
+
+ );
+ }
+}
+
+DeleteArtistModalContent.propTypes = {
+ series: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteSelectedPress: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModalContent;
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js
new file mode 100644
index 000000000..30064a4c3
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js
@@ -0,0 +1,45 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import { bulkDeleteArtist } from 'Store/Actions/seriesEditorActions';
+import DeleteArtistModalContent from './DeleteArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllSeriesSelector(),
+ (artistIds, allSeries) => {
+ const selectedSeries = _.intersectionWith(allSeries, artistIds, (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(bulkDeleteArtist({
+ artistIds: props.artistIds,
+ deleteFiles
+ }));
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteArtistModalContent);
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeSeriesModal.js b/frontend/src/Artist/Editor/Organize/OrganizeSeriesModal.js
new file mode 100644
index 000000000..c970392ec
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Editor/Organize/OrganizeSeriesModalContent.css b/frontend/src/Artist/Editor/Organize/OrganizeSeriesModalContent.css
new file mode 100644
index 000000000..0b896f4ef
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeSeriesModalContent.css
@@ -0,0 +1,8 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeSeriesModalContent.js b/frontend/src/Artist/Editor/Organize/OrganizeSeriesModalContent.js
new file mode 100644
index 000000000..10a459d52
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Editor/Organize/OrganizeSeriesModalContentConnector.js b/frontend/src/Artist/Editor/Organize/OrganizeSeriesModalContentConnector.js
new file mode 100644
index 000000000..3cbee6003
--- /dev/null
+++ b/frontend/src/Artist/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, { artistIds }) => artistIds,
+ createAllSeriesSelector(),
+ (artistIds, allSeries) => {
+ const series = _.intersectionWith(allSeries, artistIds, (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,
+ artistIds: this.props.artistIds
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render(props) {
+ return (
+
+ );
+ }
+}
+
+OrganizeSeriesModalContentConnector.propTypes = {
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeSeriesModalContentConnector);
diff --git a/frontend/src/Artist/Editor/SeriesEditor.js b/frontend/src/Artist/Editor/SeriesEditor.js
new file mode 100644
index 000000000..7039f4dde
--- /dev/null
+++ b/frontend/src/Artist/Editor/SeriesEditor.js
@@ -0,0 +1,328 @@
+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 MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import NoArtist from 'Artist/NoArtist';
+import SeriesEditorRowConnector from './SeriesEditorRowConnector';
+import SeriesEditorFooter from './SeriesEditorFooter';
+import OrganizeSeriesModal from './Organize/OrganizeSeriesModal';
+
+function getColumns(showLanguageProfile) {
+ return [
+ {
+ name: 'status',
+ 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({
+ artistIds: 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,
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ isDeleting,
+ deleteError,
+ isOrganizingSeries,
+ showLanguageProfile,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ columns
+ } = this.state;
+
+ const selectedSeriesIds = this.getSelectedIds();
+
+ return (
+
+
+
+
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+ Continuing Only
+
+
+
+ Ended Only
+
+
+
+ Missing Episodes
+
+
+
+
+
+
+
+ {
+ 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,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ 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/Artist/Editor/SeriesEditorConnector.js b/frontend/src/Artist/Editor/SeriesEditorConnector.js
new file mode 100644
index 000000000..2e16cda74
--- /dev/null
+++ b/frontend/src/Artist/Editor/SeriesEditorConnector.js
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createCommandSelector from 'Store/Selectors/createCommandSelector';
+import { setSeriesEditorSort, setSeriesEditorFilter, saveArtistEditor } from 'Store/Actions/seriesEditorActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import * as commandNames from 'Commands/commandNames';
+import SeriesEditor from './SeriesEditor';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.languageProfiles,
+ createClientSideCollectionSelector(),
+ createCommandSelector(commandNames.RENAME_SERIES),
+ (languageProfiles, series, isOrganizingSeries) => {
+ return {
+ isOrganizingSeries,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ ...series
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setSeriesEditorSort,
+ setSeriesEditorFilter,
+ saveArtistEditor,
+ fetchRootFolders
+};
+
+class SeriesEditorConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.setSeriesEditorSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue, filterType) => {
+ this.props.setSeriesEditorFilter({ filterKey, filterValue, filterType });
+ }
+
+ onSaveSelected = (payload) => {
+ this.props.saveArtistEditor(payload);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesEditorConnector.propTypes = {
+ setSeriesEditorSort: PropTypes.func.isRequired,
+ setSeriesEditorFilter: PropTypes.func.isRequired,
+ saveArtistEditor: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'series', uiSection: 'seriesEditor' }
+ )(SeriesEditorConnector);
diff --git a/frontend/src/Artist/Editor/SeriesEditorFooter.css b/frontend/src/Artist/Editor/SeriesEditorFooter.css
new file mode 100644
index 000000000..5b509936b
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Editor/SeriesEditorFooter.js b/frontend/src/Artist/Editor/SeriesEditorFooter.js
new file mode 100644
index 000000000..db7f86363
--- /dev/null
+++ b/frontend/src/Artist/Editor/SeriesEditorFooter.js
@@ -0,0 +1,314 @@
+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 TagsModal from './Tags/TagsModal';
+import DeleteArtistModal from './Delete/DeleteArtistModal';
+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,
+ isDeleteArtistModalOpen: false,
+ isTagsModalOpen: false
+ };
+ }
+
+ 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 '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({ isDeleteArtistModalOpen: true });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onTagsPress = () => {
+ this.setState({ isTagsModalOpen: true });
+ }
+
+ onTagsModalClose = () => {
+ this.setState({ isTagsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistIds,
+ selectedCount,
+ isSaving,
+ isDeleting,
+ isOrganizingSeries,
+ showLanguageProfile,
+ onOrganizeSeriesPress
+ } = this.props;
+
+ const {
+ monitored,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ seasonFolder,
+ rootFolderPath,
+ savingTags,
+ isTagsModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored', disabled: true },
+ { 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 = {
+ artistIds: 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/Artist/Editor/SeriesEditorFooterLabel.css b/frontend/src/Artist/Editor/SeriesEditorFooterLabel.css
new file mode 100644
index 000000000..9b4b40be6
--- /dev/null
+++ b/frontend/src/Artist/Editor/SeriesEditorFooterLabel.css
@@ -0,0 +1,8 @@
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.savingIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Artist/Editor/SeriesEditorFooterLabel.js b/frontend/src/Artist/Editor/SeriesEditorFooterLabel.js
new file mode 100644
index 000000000..fc77ece44
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Editor/SeriesEditorRow.css b/frontend/src/Artist/Editor/SeriesEditorRow.css
new file mode 100644
index 000000000..d53a30f6d
--- /dev/null
+++ b/frontend/src/Artist/Editor/SeriesEditorRow.css
@@ -0,0 +1,5 @@
+.seasonFolder {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/Artist/Editor/SeriesEditorRow.js b/frontend/src/Artist/Editor/SeriesEditorRow.js
new file mode 100644
index 000000000..99530d292
--- /dev/null
+++ b/frontend/src/Artist/Editor/SeriesEditorRow.js
@@ -0,0 +1,120 @@
+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 ArtistNameLink from 'Artist/ArtistNameLink';
+import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell';
+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
+};
+
+export default SeriesEditorRow;
diff --git a/frontend/src/Artist/Editor/SeriesEditorRowConnector.js b/frontend/src/Artist/Editor/SeriesEditorRowConnector.js
new file mode 100644
index 000000000..3d1ee2e71
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Editor/Tags/TagsModal.js b/frontend/src/Artist/Editor/Tags/TagsModal.js
new file mode 100644
index 000000000..0f6c2d7ec
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Editor/Tags/TagsModalContent.css b/frontend/src/Artist/Editor/Tags/TagsModalContent.css
new file mode 100644
index 000000000..63be9aadd
--- /dev/null
+++ b/frontend/src/Artist/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/Artist/Editor/Tags/TagsModalContent.js b/frontend/src/Artist/Editor/Tags/TagsModalContent.js
new file mode 100644
index 000000000..067553977
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModalContent.js
@@ -0,0 +1,188 @@
+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,
+ onApplyTagsPress
+ } = 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/Artist/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js
new file mode 100644
index 000000000..7fc5d87a8
--- /dev/null
+++ b/frontend/src/Artist/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, { artistIds }) => artistIds,
+ createAllSeriesSelector(),
+ createTagsSelector(),
+ (artistIds, allSeries, tagList) => {
+ const series = _.intersectionWith(allSeries, artistIds, (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/Artist/Index/ArtistIndex.css b/frontend/src/Artist/Index/ArtistIndex.css
new file mode 100644
index 000000000..443372a73
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndex.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/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js
new file mode 100644
index 000000000..184671df5
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndex.js
@@ -0,0 +1,326 @@
+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 NoArtist from 'Artist/NoArtist';
+import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector';
+import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
+import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector';
+import ArtistIndexFooter from './ArtistIndexFooter';
+import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu';
+import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu';
+import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu';
+import styles from './ArtistIndex.css';
+
+function getViewComponent(view) {
+ if (view === 'posters') {
+ return ArtistIndexPostersConnector;
+ }
+
+ return ArtistIndexTableConnector;
+}
+
+class ArtistIndex extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._viewComponent = null;
+
+ this.state = {
+ contentBody: null,
+ jumpBarItems: [],
+ isPosterOptionsModalOpen: false,
+ isRendered: false
+ };
+ }
+
+ componentDidMount() {
+ this.setJumpBarItems();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ if (
+ hasDifferentItems(prevProps.items, items) ||
+ sortKey !== prevProps.sortKey ||
+ sortDirection !== prevProps.sortDirection
+ ) {
+ this.setJumpBarItems();
+ }
+ }
+
+ //
+ // Control
+
+ setContentBodyRef = (ref) => {
+ this.setState({ contentBody: ref });
+ }
+
+ setViewComponentRef = (ref) => {
+ this._viewComponent = ref;
+ }
+
+ setJumpBarItems() {
+ const {
+ items,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ // Reset if not sorting by sortTitle
+ if (sortKey !== 'sortName') {
+ this.setState({ jumpBarItems: [] });
+ return;
+ }
+
+ const characters = _.reduce(items, (acc, item) => {
+ const firstCharacter = item.sortName.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 });
+ }
+
+ onJumpBarItemPress = (item) => {
+ const viewComponent = this._viewComponent.getWrappedInstance();
+ viewComponent.scrollToFirstCharacter(item);
+ }
+
+ 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,
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ view,
+ isRefreshingSeries,
+ isRssSyncExecuting,
+ scrollTop,
+ onSortSelect,
+ onFilterSelect,
+ onViewSelect,
+ onRefreshSeriesPress,
+ onRssSyncPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ contentBody,
+ jumpBarItems,
+ isPosterOptionsModalOpen,
+ isRendered
+ } = this.state;
+
+ const ViewComponent = getViewComponent(view);
+ const isLoaded = !error && isPopulated && !!items.length && contentBody;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {
+ view === 'posters' &&
+
+ }
+
+ {
+ view === 'posters' &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load series
+ }
+
+ {
+ isLoaded &&
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+ {
+ isLoaded && !!jumpBarItems.length &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+ArtistIndex.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ 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 ArtistIndex;
diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js
new file mode 100644
index 000000000..794dcaa41
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexConnector.js
@@ -0,0 +1,160 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import dimensions from 'Styles/Variables/dimensions';
+import createCommandSelector from 'Store/Selectors/createCommandSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { fetchArtist } from 'Store/Actions/seriesActions';
+import scrollPositions from 'Store/scrollPositions';
+import { setArtistSort, setArtistFilter, setArtistView } from 'Store/Actions/artistIndexActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import withScrollPosition from 'Components/withScrollPosition';
+import ArtistIndex from './ArtistIndex';
+
+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(
+ (state) => state.series,
+ (state) => state.seriesIndex,
+ createCommandSelector(commandNames.REFRESH_SERIES),
+ createCommandSelector(commandNames.RSS_SYNC),
+ createDimensionsSelector(),
+ (series, seriesIndex, isRefreshingSeries, isRssSyncExecuting, dimensionsState) => {
+ return {
+ isRefreshingSeries,
+ isRssSyncExecuting,
+ isSmallScreen: dimensionsState.isSmallScreen,
+ ...series,
+ ...seriesIndex
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchArtist,
+ setArtistSort,
+ setArtistFilter,
+ setArtistView,
+ executeCommand
+};
+
+class ArtistIndexConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ view,
+ scrollTop,
+ isSmallScreen
+ } = props;
+
+ this.state = {
+ scrollTop: getScrollTop(view, scrollTop, isSmallScreen)
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchArtist();
+ }
+
+ //
+ // Listeners
+
+ onSortSelect = (sortKey) => {
+ this.props.setArtistSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue, filterType) => {
+ this.props.setArtistFilter({ filterKey, filterValue, filterType });
+ }
+
+ onViewSelect = (view) => {
+ // Reset the scroll position before changing the view
+ this.setState({ scrollTop: 0 }, () => {
+ this.props.setArtistView({ 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 (
+
+ );
+ }
+}
+
+ArtistIndexConnector.propTypes = {
+ view: PropTypes.string.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ fetchArtist: PropTypes.func.isRequired,
+ setArtistSort: PropTypes.func.isRequired,
+ setArtistFilter: PropTypes.func.isRequired,
+ setArtistView: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withScrollPosition(
+ connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexConnector),
+ 'seriesIndex'
+);
diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.css b/frontend/src/Artist/Index/ArtistIndexFooter.css
new file mode 100644
index 000000000..3aa369576
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFooter.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/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js
new file mode 100644
index 000000000..70664e528
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFooter.js
@@ -0,0 +1,104 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './ArtistIndexFooter.css';
+
+function ArtistIndexFooter({ series }) {
+ const count = series.length;
+ let episodes = 0;
+ let episodeFiles = 0;
+ let ended = 0;
+ let continuing = 0;
+ let monitored = 0;
+
+ series.forEach((s) => {
+ episodes += s.trackCount || 0;
+ episodeFiles += s.trackFileCount || 0;
+
+ if (s.status === 'ended') {
+ ended++;
+ } else {
+ continuing++;
+ }
+
+ if (s.monitored) {
+ monitored++;
+ }
+ });
+
+ return (
+
+
+
+
+
Continuing (All tracks downloaded)
+
+
+
+
+
Ended (All tracks downloaded)
+
+
+
+
+
Missing Tracks (Artist monitored)
+
+
+
+
+
Missing Tracks (Artist not monitored)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+ArtistIndexFooter.propTypes = {
+ series: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default ArtistIndexFooter;
diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js
new file mode 100644
index 000000000..2f9e64919
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexItemConnector.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 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 createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state, { seasons }) => seasons,
+ createQualityProfileSelector(),
+ createLanguageProfileSelector(),
+ createCommandsSelector(),
+ (artistId, seasons, qualityProfile, languageProfile, commands) => {
+ const isRefreshingSeries = _.some(commands, (command) => {
+ return command.name === commandNames.REFRESH_SERIES &&
+ command.body.artistId === artistId;
+ });
+
+ const latestSeason = _.maxBy(seasons, (season) => season.seasonNumber);
+
+ return {
+ qualityProfile,
+ languageProfile,
+ latestSeason,
+ isRefreshingSeries
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class ArtistIndexItemConnector extends Component {
+
+ //
+ // Listeners
+
+ onRefreshSeriesPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_SERIES,
+ artistId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ component: ItemComponent,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+ArtistIndexItemConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ component: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector);
diff --git a/frontend/src/Artist/Index/ArtistIndexPage.js b/frontend/src/Artist/Index/ArtistIndexPage.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js
new file mode 100644
index 000000000..2fab66a36
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+
+function ArtistIndexFilterMenu(props) {
+ const {
+ filterKey,
+ filterValue,
+ onFilterSelect
+ } = props;
+
+ return (
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+ Continuing Only
+
+
+
+ Ended Only
+
+
+
+ Missing Episodes
+
+
+
+ );
+}
+
+ArtistIndexFilterMenu.propTypes = {
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexFilterMenu;
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js
new file mode 100644
index 000000000..6941e0536
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js
@@ -0,0 +1,145 @@
+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 ArtistIndexSortMenu(props) {
+ const {
+ sortKey,
+ sortDirection,
+ onSortSelect
+ } = props;
+
+ return (
+
+
+
+ Name
+
+
+
+ Network
+
+
+
+ Quality Profile
+
+
+
+ Language Profile
+
+
+
+ Next Airing
+
+
+
+ Previous Airing
+
+
+
+ Added
+
+
+
+ Albums
+
+
+
+ Tracks
+
+
+
+ Track Count
+
+
+
+ Latest Season
+
+
+
+ Path
+
+
+
+ Size on Disk
+
+
+
+ );
+}
+
+ArtistIndexSortMenu.propTypes = {
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ onSortSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexSortMenu;
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js
new file mode 100644
index 000000000..7e3e35764
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js
@@ -0,0 +1,42 @@
+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 ArtistIndexViewMenu(props) {
+ const {
+ view,
+ onViewSelect
+ } = props;
+
+ return (
+
+
+
+ Table
+
+
+
+ Posters
+
+
+
+ );
+}
+
+ArtistIndexViewMenu.propTypes = {
+ view: PropTypes.string.isRequired,
+ onViewSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexViewMenu;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css
new file mode 100644
index 000000000..eb1fd8be0
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css
@@ -0,0 +1,88 @@
+$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;
+
+ // Transforming causes the content to shift slightly
+ // transform: scale($hoverScale);
+
+ .controls {
+ opacity: 0.9;
+ transition: opacity 200ms linear 150ms;
+ }
+ }
+}
+
+.posterContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ display: block;
+ background-color: $defaultColor;
+}
+
+.nextAiring {
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.title {
+ composes: truncate from 'Styles/mixins/truncate.css';
+
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ 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;
+ border-radius: 4px;
+ background-color: #216044;
+ 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/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js
new file mode 100644
index 000000000..2d5fc45b9
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js
@@ -0,0 +1,230 @@
+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 ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistIndexPosterProgressBar from './ArtistIndexPosterProgressBar';
+import ArtistIndexPosterInfo from './ArtistIndexPosterInfo';
+import styles from './ArtistIndexPoster.css';
+
+class ArtistIndexPoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ artistName,
+ monitored,
+ status,
+ nameSlug,
+ nextAiring,
+ trackCount,
+ trackFileCount,
+ images,
+ posterWidth,
+ posterHeight,
+ detailedProgressBar,
+ showTitle,
+ showQualityProfile,
+ qualityProfile,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isRefreshingSeries,
+ onRefreshSeriesPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const link = `/series/${nameSlug}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+ {
+ showTitle &&
+
+ {artistName}
+
+ }
+
+ {
+ showQualityProfile &&
+
+ {qualityProfile.name}
+
+ }
+
+
+ {
+ getRelativeDate(
+ nextAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexPoster.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ trackCount: PropTypes.number,
+ trackFileCount: PropTypes.number,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired
+};
+
+ArtistIndexPoster.defaultProps = {
+ trackCount: 0,
+ trackFileCount: 0
+};
+
+export default ArtistIndexPoster;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css
new file mode 100644
index 000000000..cab3dec61
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css
@@ -0,0 +1,6 @@
+.info {
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js
new file mode 100644
index 000000000..5a738cc0e
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import styles from './ArtistIndexPosterInfo.css';
+
+function ArtistIndexPosterInfo(props) {
+ const {
+ network,
+ qualityProfile,
+ previousAiring,
+ added,
+ albumCount,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (sortKey === 'network' && network) {
+ return (
+
+ {network}
+
+ );
+ }
+
+ if (sortKey === 'qualityProfileId') {
+ 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 === 'albumCount') {
+ let seasons = '1 season';
+
+ if (albumCount === 0) {
+ seasons = 'No seasons';
+ } else if (albumCount > 1) {
+ seasons = `${albumCount} seasons`;
+ }
+
+ return (
+
+ {seasons}
+
+ );
+ }
+
+ if (sortKey === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (sortKey === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ return null;
+}
+
+ArtistIndexPosterInfo.propTypes = {
+ network: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: 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 ArtistIndexPosterInfo;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterProgressBar.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosterProgressBar.css
new file mode 100644
index 000000000..dbf3499ab
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterProgressBar.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/Artist/Index/Posters/ArtistIndexPosterProgressBar.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterProgressBar.js
new file mode 100644
index 000000000..1ab803251
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterProgressBar.js
@@ -0,0 +1,45 @@
+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 './ArtistIndexPosterProgressBar.css';
+
+function ArtistIndexPosterProgressBar(props) {
+ const {
+ monitored,
+ status,
+ trackCount,
+ trackFileCount,
+ posterWidth,
+ detailedProgressBar
+ } = props;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+ const text = `${trackFileCount} / ${trackCount}`;
+
+ return (
+
+ );
+}
+
+ArtistIndexPosterProgressBar.propTypes = {
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ trackCount: PropTypes.number.isRequired,
+ trackFileCount: PropTypes.number.isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired
+};
+
+export default ArtistIndexPosterProgressBar;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js
new file mode 100644
index 000000000..b815d5708
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js
@@ -0,0 +1,326 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import { Grid, WindowScroller } from 'react-virtualized';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexPoster from './ArtistIndexPoster';
+import styles from './ArtistIndexPosters.css';
+
+// Poster container dimensions
+const columnPadding = 20;
+const columnPaddingSmallScreen = 10;
+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,
+ showQualityProfile
+ } = posterOptions;
+
+ const nextAiringHeight = 19;
+
+ const heights = [
+ posterHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ nextAiringHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ if (showTitle) {
+ 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;
+ }
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return Math.ceil((250 / 170) * posterWidth);
+}
+
+class ArtistIndexPosters 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,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ posterOptions
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.posterOptions !== posterOptions ||
+ itemsChanged
+ ) {
+ this.calculateGrid();
+ }
+
+ if (
+ prevProps.filterKey !== filterKey ||
+ prevProps.filterValue !== filterValue ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+ const {
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const index = _.findIndex(items, (item) => {
+ const firstCharacter = item.sortTitle.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+
+ if (index != null) {
+ const row = Math.floor(index / columnCount);
+ const scrollTop = rowHeight * row;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+
+ 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, this.props.posterOptions.size);
+ 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,
+ 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 (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+ArtistIndexPosters.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ posterOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ 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 ArtistIndexPosters;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js
new file mode 100644
index 000000000..9e200cfa6
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js
@@ -0,0 +1,33 @@
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import ArtistIndexPosters from './ArtistIndexPosters';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesIndex.posterOptions,
+ createClientSideCollectionSelector(),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (posterOptions, series, uiSettings, dimensions) => {
+ return {
+ posterOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...series
+ };
+ }
+ );
+}
+
+export default connectSection(
+ createMapStateToProps,
+ undefined,
+ undefined,
+ { withRef: true },
+ { section: 'series', uiSection: 'seriesIndex' }
+ )(ArtistIndexPosters);
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js
new file mode 100644
index 000000000..e1b0a257a
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector';
+
+function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+ArtistIndexPosterOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexPosterOptionsModal;
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js
new file mode 100644
index 000000000..275af0528
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js
@@ -0,0 +1,173 @@
+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 ArtistIndexPosterOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showTitle: props.showTitle,
+ showQualityProfile: props.showQualityProfile
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showQualityProfile
+ } = 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 (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ 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,
+ showQualityProfile
+ } = this.state;
+
+ return (
+
+
+ Poster Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+ArtistIndexPosterOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ onChangePosterOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexPosterOptionsModalContent;
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js
new file mode 100644
index 000000000..878518647
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistPosterOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.seriesIndex,
+ (seriesIndex) => {
+ return seriesIndex.posterOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangePosterOption(payload) {
+ dispatch(setArtistPosterOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent);
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js
new file mode 100644
index 000000000..106f07327
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.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 EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+
+class ArtistIndexActionsCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ isRefreshingSeries,
+ onRefreshSeriesPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexActionsCell.propTypes = {
+ id: PropTypes.number.isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired
+};
+
+export default ArtistIndexActionsCell;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css
new file mode 100644
index 000000000..f5095d9dd
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css
@@ -0,0 +1,81 @@
+.status {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortName {
+ 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 {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 180px;
+}
+
+.albumCount {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 100px;
+}
+
+.trackProgress,
+.latestSeason {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 150px;
+}
+
+.trackCount {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 120px;
+}
+
+.path {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 110px;
+}
+
+.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 0 70px;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js
new file mode 100644
index 000000000..8c1bd8682
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js
@@ -0,0 +1,106 @@
+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 styles from './ArtistIndexHeader.css';
+
+class ArtistIndexHeader 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 {
+ 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}
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+ArtistIndexHeader.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default ArtistIndexHeader;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js
new file mode 100644
index 000000000..37ddd9ef3
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { setArtistTableOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexHeader from './ArtistIndexHeader';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setArtistTableOption(payload));
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader);
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css
new file mode 100644
index 000000000..23f061f52
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css
@@ -0,0 +1,90 @@
+.status {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortName {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 4 0 110px;
+}
+
+.network {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 2 0 90px;
+}
+
+.qualityProfileId,
+.languageProfileId {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 125px;
+}
+
+.nextAiring,
+.previousAiring,
+.added {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 180px;
+}
+
+.albumCount {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 100px;
+}
+
+.trackProgress,
+.latestSeason {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: flex;
+ justify-content: center;
+ flex: 0 0 150px;
+ flex-direction: column;
+}
+
+.trackCount {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 120px;
+}
+
+.path {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 110px;
+}
+
+.tags {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 60px;
+}
+
+.useSceneNumbering {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 145px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 70px;
+}
+
+.checkInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js
new file mode 100644
index 000000000..44df146f4
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.js
@@ -0,0 +1,389 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+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 ArtistNameLink from 'Artist/ArtistNameLink';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistStatusCell from './ArtistStatusCell';
+import styles from './ArtistIndexRow.css';
+
+class ArtistIndexRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ onEditSeriesPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditSeriesModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteSeriesPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteSeriesModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onUseSceneNumberingChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ monitored,
+ status,
+ artistName,
+ nameSlug,
+ network,
+ qualityProfile,
+ languageProfile,
+ nextAiring,
+ previousAiring,
+ added,
+ albumCount,
+ trackCount,
+ trackFileCount,
+ totalTrackCount,
+ latestSeason,
+ path,
+ sizeOnDisk,
+ tags,
+ // useSceneNumbering,
+ columns,
+ isRefreshingSeries,
+ onRefreshSeriesPress
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'status') {
+ return (
+
+ );
+ }
+
+ if (name === 'sortName') {
+ return (
+
+
+
+ );
+ }
+
+ 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 === 'albumCount') {
+ return (
+
+ {albumCount}
+
+ );
+ }
+
+ if (name === 'trackProgress') {
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'latestSeason') {
+ const seasonStatistics = latestSeason.statistics;
+ const progress = seasonStatistics.episodeCount ? seasonStatistics.episodeFileCount / seasonStatistics.episodeCount * 100 : 100;
+
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'trackCount') {
+ return (
+
+ {totalTrackCount}
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (name === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ if (name === 'tags') {
+ return (
+
+
+
+ );
+ }
+
+ // if (name === 'useSceneNumbering') {
+ // return (
+ //
+ //
+ //
+ // );
+ // }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ network: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ languageProfile: PropTypes.object.isRequired,
+ nextAiring: PropTypes.string,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ trackCount: PropTypes.number,
+ trackFileCount: PropTypes.number,
+ totalTrackCount: PropTypes.number,
+ latestSeason: PropTypes.object,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ // useSceneNumbering: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isRefreshingSeries: PropTypes.bool.isRequired,
+ onRefreshSeriesPress: PropTypes.func.isRequired
+};
+
+ArtistIndexRow.defaultProps = {
+ trackCount: 0,
+ trackFileCount: 0
+};
+
+export default ArtistIndexRow;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css b/frontend/src/Artist/Index/Table/ArtistIndexTable.css
new file mode 100644
index 000000000..e46160a96
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css
@@ -0,0 +1,5 @@
+.tableContainer {
+ composes: tableContainer from 'Components/Table/VirtualTable.css';
+
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js
new file mode 100644
index 000000000..59b4c23be
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.js
@@ -0,0 +1,143 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sortDirections } from 'Helpers/Props';
+import VirtualTable from 'Components/Table/VirtualTable';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector';
+import ArtistIndexRow from './ArtistIndexRow';
+import styles from './ArtistIndexTable.css';
+
+class ArtistIndexTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._table = null;
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ columns,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ if (prevProps.columns !== columns ||
+ prevProps.filterKey !== filterKey ||
+ prevProps.filterValue !== filterValue ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection
+ ) {
+ this._table.forceUpdateGrid();
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+
+ const row = _.findIndex(items, (item) => {
+ const firstCharacter = item.sortTitle.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+
+ if (row != null) {
+ this._table.scrollToRow(row);
+ }
+ }
+
+ setTableRef = (ref) => {
+ this._table = ref;
+ }
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ columns
+ } = this.props;
+
+ const series = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ columns,
+ sortKey,
+ sortDirection,
+ isSmallScreen,
+ scrollTop,
+ contentBody,
+ onSortPress,
+ onRender,
+ onScroll
+ } = this.props;
+
+ return (
+
+ }
+ onRender={onRender}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+ArtistIndexTable.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ scrollTop: PropTypes.number.isRequired,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndexTable;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js
new file mode 100644
index 000000000..f94950b87
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js
@@ -0,0 +1,34 @@
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setArtistSort } from 'Store/Actions/artistIndexActions';
+import ArtistIndexTable from './ArtistIndexTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ createClientSideCollectionSelector(),
+ (dimensions, series) => {
+ return {
+ isSmallScreen: dimensions.isSmallScreen,
+ ...series
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSortPress(sortKey) {
+ dispatch(setArtistSort({ sortKey }));
+ }
+ };
+}
+
+export default connectSection(
+ createMapStateToProps,
+ createMapDispatchToProps,
+ undefined,
+ { withRef: true },
+ { section: 'series', uiSection: 'seriesIndex' }
+ )(ArtistIndexTable);
diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.css b/frontend/src/Artist/Index/Table/ArtistStatusCell.css
new file mode 100644
index 000000000..d19ddf05b
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.css
@@ -0,0 +1,9 @@
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 60px;
+}
+
+.statusIcon {
+ width: 20px;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.js b/frontend/src/Artist/Index/Table/ArtistStatusCell.js
new file mode 100644
index 000000000..734e50722
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.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 './ArtistStatusCell.css';
+
+function ArtistStatusCell(props) {
+ const {
+ className,
+ monitored,
+ status,
+ component: Component,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+}
+
+ArtistStatusCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ component: PropTypes.func
+};
+
+ArtistStatusCell.defaultProps = {
+ className: styles.status,
+ component: VirtualTableRowCell
+};
+
+export default ArtistStatusCell;
diff --git a/frontend/src/Artist/Index/Table/artistIndexCellRenderers.js b/frontend/src/Artist/Index/Table/artistIndexCellRenderers.js
new file mode 100644
index 000000000..b15554482
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/artistIndexCellRenderers.js
@@ -0,0 +1,138 @@
+import React from 'react';
+import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import ProgressBar from 'Components/ProgressBar';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexActionsCell from './ArtistIndexActionsCell';
+import ArtistStatusCell from './ArtistStatusCell';
+
+export default function artistIndexCellRenderers(cellProps) {
+ const {
+ cellKey,
+ dataKey,
+ rowData,
+ ...otherProps
+ } = cellProps;
+
+ const {
+ id,
+ monitored,
+ status,
+ name,
+ nameSlug,
+ network,
+ qualityProfileId,
+ nextAiring,
+ previousAiring,
+ albumCount,
+ trackCount,
+ trackFileCount
+ } = rowData;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ if (dataKey === 'status') {
+ return (
+
+ );
+ }
+
+ if (dataKey === 'sortTitle') {
+ return (
+
+
+
+
+ );
+ }
+
+ if (dataKey === 'network') {
+ return (
+
+ {network}
+
+
+ );
+ }
+
+ if (dataKey === 'qualityProfileId') {
+ return (
+
+
+
+ );
+ }
+
+ if (dataKey === 'nextAiring') {
+ return (
+
+ );
+ }
+
+ if (dataKey === 'seasonCount') {
+ return (
+
+ {albumCount}
+
+ );
+ }
+
+ if (dataKey === 'episodeProgress') {
+ return (
+
+
+
+ );
+ }
+
+ if (dataKey === 'actions') {
+ return (
+
+ );
+ }
+}
diff --git a/frontend/src/Artist/NoArtist.css b/frontend/src/Artist/NoArtist.css
new file mode 100644
index 000000000..38a01f391
--- /dev/null
+++ b/frontend/src/Artist/NoArtist.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/Artist/NoArtist.js b/frontend/src/Artist/NoArtist.js
new file mode 100644
index 000000000..b6e90cf63
--- /dev/null
+++ b/frontend/src/Artist/NoArtist.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import styles from './NoArtist.css';
+
+function NoArtist() {
+ return (
+
+
+ No artist found, to get started you'll want to add a new artist or import some existing ones.
+
+
+
+
+ Import Existing Artist(s)
+
+
+
+
+
+ Add New Artist
+
+
+
+ );
+}
+
+export default NoArtist;
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..94d36a125
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css
@@ -0,0 +1,113 @@
+.event {
+ display: flex;
+ overflow-x: hidden;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ font-size: 14px;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.status {
+ width: 10px;
+ border-left-width: 4px;
+ border-left-style: solid;
+}
+
+.date {
+ flex: 0 0 250px;
+ font-weight: bold;
+}
+
+.time {
+ flex: 0 0 120px;
+ margin-right: 10px;
+}
+
+.seriesTitle,
+.episodeTitle {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ flex: 0 1 300px;
+ margin-right: 10px;
+}
+
+.episodeTitle {
+ flex: 1 1 1px;
+}
+
+.seasonEpisodeNumber {
+ flex: 0 0 100px;
+}
+
+.episodeSeparator {
+ display: none;
+}
+
+.absoluteEpisodeNumber {
+ 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';
+}
+
+.unaired {
+ composes: unaired from 'Calendar/Events/CalendarEvent.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .event {
+ position: relative;
+ flex-wrap: wrap;
+ padding-left: 10px;
+ }
+
+ .status {
+ position: absolute;
+ top: 7%;
+ left: 0;
+ height: 86%;
+ }
+
+ .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..028f969dd
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.js
@@ -0,0 +1,160 @@
+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 } 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,
+ title,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDateUtc,
+ monitored,
+ hasFile,
+ grabbed,
+ queueItem,
+ showDate,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ const startTime = moment(airDateUtc);
+ const endTime = startTime.add(series.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = series.monitored && monitored;
+ const statusStyle = getStatusStyle(episodeNumber, hasFile, downloading, startTime, endTime, isMonitored);
+
+ return (
+
+
+
+ {
+ showDate &&
+ startTime.format(longDateFormat)
+ }
+
+
+
+
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
+
+
+
+ {series.title}
+
+
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ {
+ series.seriesType === 'anime' && absoluteEpisodeNumber &&
+
({absoluteEpisodeNumber})
+ }
+
+
-
+
+
+
+ {title}
+
+
+ {
+ !!queueItem &&
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AgendaEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ series: PropTypes.object.isRequired,
+ 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,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.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..d6d4c8cc5
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import AgendaEvent from './AgendaEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (series, queueItem, uiSettings) => {
+ return {
+ series,
+ queueItem,
+ timeFormat: uiSettings.timeFormat,
+ longDateFormat: uiSettings.longDateFormat
+ };
+ }
+ );
+}
+
+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..993aeef73
--- /dev/null
+++ b/frontend/src/Calendar/CalendarConnector.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 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 Calendar from './Calendar';
+
+const UPDATE_DELAY = 3600000; // 1 hour
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...calendarActions,
+ fetchEpisodeFiles,
+ clearEpisodeFiles,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class CalendarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ this.props.gotoCalendarToday();
+ this.scheduleUpdate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ time
+ } = this.props;
+
+ if (hasDifferentItems(prevProps.items, items)) {
+ const episodeIds = selectUniqueIds(items, 'id');
+ const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
+
+ this.props.fetchQueueDetails({ episodeIds });
+
+ if (episodeFileIds.length) {
+ this.props.fetchEpisodeFiles({ episodeFileIds });
+ }
+ }
+
+ if (prevProps.time !== time) {
+ this.scheduleUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearCalendar();
+ this.props.clearQueueDetails();
+ this.props.clearEpisodeFiles();
+ this.clearUpdateTimeout();
+ }
+
+ //
+ // Control
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+
+ this.updateTimeoutId = setTimeout(this.scheduleUpdate, 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,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ setCalendarView: PropTypes.func.isRequired,
+ gotoCalendarToday: PropTypes.func.isRequired,
+ gotoCalendarPreviousRange: PropTypes.func.isRequired,
+ gotoCalendarNextRange: PropTypes.func.isRequired,
+ clearCalendar: 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..b54900b17
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.js
@@ -0,0 +1,134 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Measure from 'react-measure';
+import { align, icons } from 'Helpers/Props';
+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 MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import CalendarLinkModal from './iCal/CalendarLinkModal';
+import Legend from './Legend/Legend';
+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,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({ width }, () => {
+ const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
+ console.log(`${width} || ${days}`);
+ this.props.onDaysCountChange(days);
+ });
+ }
+
+ onFilterMenuItemPress = (filterKey, unmonitored) => {
+ this.props.onUnmonitoredChange(unmonitored);
+ }
+
+ onGetCalendarLinkPress = () => {
+ this.setState({ isCalendarLinkModalOpen: true });
+ }
+
+ onGetCalendarLinkModalClose = () => {
+ this.setState({ isCalendarLinkModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ unmonitored,
+ colorImpairedMode
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+CalendarPage.propTypes = {
+ unmonitored: PropTypes.bool.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onDaysCountChange: PropTypes.func.isRequired,
+ onUnmonitoredChange: 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..59de66f74
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPageConnector.js
@@ -0,0 +1,33 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setCalendarDaysCount, setCalendarIncludeUnmonitored } from 'Store/Actions/calendarActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarPage from './CalendarPage';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createUISettingsSelector(),
+ (calendar, uiSettings) => {
+ return {
+ unmonitored: calendar.unmonitored,
+ showUpcoming: calendar.showUpcoming,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDaysCountChange(dayCount) {
+ dispatch(setCalendarDaysCount({ dayCount }));
+ },
+
+ onUnmonitoredChange(unmonitored) {
+ dispatch(setCalendarIncludeUnmonitored({ unmonitored }));
+ }
+ };
+}
+
+export default 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..5e61e7364
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.js
@@ -0,0 +1,61 @@
+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 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) => {
+ 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..c825c91fb
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDayConnector.js
@@ -0,0 +1,55 @@
+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 createCalendarEventsConnector() {
+ return createSelector(
+ (state, { date }) => date,
+ (state) => state.calendar,
+ (date, calendar) => {
+ const filtered = _.filter(calendar.items, (item) => {
+ return moment(date).isSame(moment(item.airDateUtc), 'day');
+ });
+
+ return _.sortBy(filtered, (item) => moment(item.airDateUtc).unix());
+ }
+ );
+}
+
+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..65a894081
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.js
@@ -0,0 +1,163 @@
+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..9dd965146
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js
@@ -0,0 +1,32 @@
+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
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch) {
+ return {
+ onNavigatePrevious() {
+ dispatch(gotoCalendarPreviousRange());
+ },
+
+ onNavigateNext() {
+ dispatch(gotoCalendarNextRange());
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(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..b8ab0a43b
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.js
@@ -0,0 +1,55 @@
+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..8b4a5372b
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.css
@@ -0,0 +1,86 @@
+.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 {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ flex: 1 0 1px;
+ margin-right: 10px;
+}
+
+.seriesTitle {
+ color: #3a3f51;
+ font-size: 14px;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ border-left-color: $successColor;
+}
+
+.downloading {
+ border-left-color: $purple;
+}
+
+.unmonitored {
+ border-left-color: $gray;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(45deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
+
+.onAir {
+ border-left-color: $warningColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
+
+.missing {
+ border-left-color: $dangerColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
+
+.premiere {
+ border-left-color: $sonarrBlue;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
+
+.unaired {
+ border-left-color: $primaryColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js
new file mode 100644
index 000000000..57c2a4722
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.js
@@ -0,0 +1,165 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } 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,
+ title,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDateUtc,
+ monitored,
+ hasFile,
+ grabbed,
+ queueItem,
+ timeFormat,
+ colorImpairedMode
+ } = this.props;
+
+ const startTime = moment(airDateUtc);
+ const endTime = startTime.add(series.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = series.monitored && monitored;
+ const statusStyle = getStatusStyle(episodeNumber, hasFile, downloading, startTime, endTime, isMonitored);
+ const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
+
+ return (
+
+
+
+
+ {series.title}
+
+
+ {
+ missingAbsoluteNumber &&
+
+ }
+
+ {
+ !!queueItem &&
+
+
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+
+
+
+ {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,
+ 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,
+ 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..08d88813d
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarEvent from './CalendarEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (series, queueItem, uiSettings) => {
+ return {
+ series,
+ queueItem,
+ timeFormat: uiSettings.timeFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarEvent);
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..5214954b6
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import LegendItem from './LegendItem';
+import styles from './Legend.css';
+
+function Legend({ colorImpairedMode }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+Legend.propTypes = {
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default Legend;
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/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..ca4a02e0e
--- /dev/null
+++ b/frontend/src/Calendar/getStatusStyle.js
@@ -0,0 +1,33 @@
+import moment from 'moment';
+
+function getStatusStyle(episodeNumber, 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';
+ }
+
+ if (episodeNumber === 1) {
+ return 'premiere';
+ }
+
+ 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..4942258da
--- /dev/null
+++ b/frontend/src/Commands/commandNames.js
@@ -0,0 +1,19 @@
+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 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..e500ca154
--- /dev/null
+++ b/frontend/src/Components/Card.css
@@ -0,0 +1,8 @@
+.card {
+ margin: 10px;
+ padding: 10px;
+ border-radius: 3px;
+ background-color: $white;
+ box-shadow: 0 0 10px 1px $cardShadowColor;
+ color: $defaultColor;
+}
diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js
new file mode 100644
index 000000000..cc45edba3
--- /dev/null
+++ b/frontend/src/Components/Card.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './Card.css';
+
+class Card extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ onPress
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+Card.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+Card.defaultProps = {
+ className: styles.card
+};
+
+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..d340af170
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.js
@@ -0,0 +1,140 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import colors from 'Styles/Variables/colors';
+import styles from './CircularProgressBar.css';
+
+class CircularProgressBar extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ progress: 0
+ };
+ }
+
+ componentDidMount() {
+ this._progressStep();
+ }
+
+ componentDidUpdate(prevProps) {
+ const progress = this.props.progress;
+
+ if (prevProps.progress !== progress) {
+ this._cancelProgressStep();
+ this._progressStep();
+ }
+ }
+
+ componentWillUnmount() {
+ this._cancelProgressStep();
+ }
+
+ //
+ // Control
+
+ _progressStep() {
+ this.requestAnimationFrame = window.requestAnimationFrame(() => {
+ this.setState({
+ progress: this.state.progress + 1
+ }, () => {
+ if (this.state.progress < this.props.progress) {
+ this._progressStep();
+ }
+ });
+ });
+ }
+
+ _cancelProgressStep() {
+ if (this.requestAnimationFrame) {
+ window.cancelAnimationFrame(this.requestAnimationFrame);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ containerClassName,
+ size,
+ strokeWidth,
+ strokeColor,
+ showProgressText
+ } = this.props;
+
+ const progress = this.state.progress;
+
+ const center = size / 2;
+ const radius = center - strokeWidth;
+ const circumference = Math.PI * (radius * 2);
+ const sizeInPixels = `${size}px`;
+ const strokeDashoffset = ((100 - progress) / 100) * circumference;
+ const progressText = `${Math.round(progress)}%`;
+
+ return (
+
+
+
+
+
+
+ {
+ 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..94cd75ba9
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.css
@@ -0,0 +1,4 @@
+.descriptionList {
+ margin-top: 0;
+ margin-bottom: 20px;
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js
new file mode 100644
index 000000000..b7a1d1634
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './DescriptionList.css';
+
+class DescriptionList extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+DescriptionList.propTypes = {
+ children: PropTypes.node
+};
+
+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..582eaff24
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
@@ -0,0 +1,13 @@
+.description {
+ line-height: 1.528571429;
+}
+
+.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..a1eb377cb
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css
@@ -0,0 +1,18 @@
+.title {
+ line-height: 1.528571429;
+}
+
+.title {
+ font-weight: bold;
+}
+
+@media (min-width: 768px) {
+ .title {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ float: left;
+ clear: left;
+ width: 160px;
+ text-align: right;
+ }
+}
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/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..7da2ea225
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css
@@ -0,0 +1,16 @@
+.modalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex-direction: column;
+}
+
+.pathInput {
+ composes: pathInputWrapper from 'Components/Form/PathInput.css';
+
+ flex: 0 0 auto;
+}
+
+.scroller {
+ margin-top: 20px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
new file mode 100644
index 000000000..f81019e1c
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
@@ -0,0 +1,213 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { scrollDirections } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Scroller from 'Components/Scroller/Scroller';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import PathInput from 'Components/Form/PathInput';
+import FileBrowserRow from './FileBrowserRow';
+import styles from './FileBrowserModalContent.css';
+
+const columns = [
+ {
+ name: 'type',
+ label: 'Type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ }
+];
+
+class FileBrowserModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scrollerNode = null;
+
+ this.state = {
+ isFileBrowserModalOpen: false,
+ currentPath: props.value
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ currentPath
+ } = this.props;
+
+ if (currentPath !== this.state.currentPath) {
+ this.setState({ currentPath });
+ this._scrollerNode.scrollTop = 0;
+ }
+ }
+
+ //
+ // Control
+
+ setScrollerRef = (ref) => {
+ if (ref) {
+ this._scrollerNode = ReactDOM.findDOMNode(ref);
+ } else {
+ this._scrollerNode = null;
+ }
+ }
+
+ //
+ // Listeners
+
+ onPathInputChange = ({ value }) => {
+ this.setState({ currentPath: value });
+ }
+
+ onRowPress = (path) => {
+ this.props.onFetchPaths(path);
+ }
+
+ onOkPress = () => {
+ this.props.onChange({
+ name: this.props.name,
+ value: this.state.currentPath
+ });
+
+ this.props.onClearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ parent,
+ directories,
+ files,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const emptyParent = parent === '';
+
+ return (
+
+
+ File Browser
+
+
+
+
+
+
+
+
+ {
+ emptyParent &&
+
+ }
+
+ {
+ !emptyParent && parent &&
+
+ }
+
+ {
+ directories.map((directory) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ files.map((file) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Ok
+
+
+
+ );
+ }
+}
+
+FileBrowserModalContent.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ parent: PropTypes.string,
+ currentPath: PropTypes.string.isRequired,
+ directories: PropTypes.arrayOf(PropTypes.object).isRequired,
+ files: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onFetchPaths: PropTypes.func.isRequired,
+ onClearPaths: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FileBrowserModalContent;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
new file mode 100644
index 000000000..adf52fbcd
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.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 { fetchPaths, clearPaths } from 'Store/Actions/pathActions';
+import FileBrowserModalContent from './FileBrowserModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.paths,
+ (paths) => {
+ const {
+ parent,
+ currentPath,
+ directories,
+ files
+ } = paths;
+
+ const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return {
+ parent,
+ currentPath,
+ directories,
+ files,
+ paths: filteredPaths
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchPaths,
+ clearPaths
+};
+
+class FileBrowserModalContentConnector extends Component {
+
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchPaths({ path: this.props.value });
+ }
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ this.props.fetchPaths({ path });
+ }
+
+ onClearPaths = () => {
+ // this.props.clearPaths();
+ }
+
+ onModalClose = () => {
+ this.props.clearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+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/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..a8600255a
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.js
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReCAPTCHA from 'react-google-recaptcha';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputButton from './FormInputButton';
+import TextInput from './TextInput';
+import styles from './CaptchaInput.css';
+
+function CaptchaInput(props) {
+ const {
+ className,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ refreshing,
+ siteKey,
+ secretToken,
+ onChange,
+ onRefreshPress,
+ onCaptchaChange
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ !!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..0fc0be9cd
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.js
@@ -0,0 +1,187 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputHelpText from './FormInputHelpText';
+import styles from './CheckInput.css';
+
+class CheckInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._checkbox = null;
+ }
+
+ componentDidMount() {
+ this.setIndeterminate();
+ }
+
+ componentDidUpdate() {
+ this.setIndeterminate();
+ }
+
+ //
+ // Control
+
+ setIndeterminate() {
+ if (!this._checkbox) {
+ return;
+ }
+
+ const {
+ value,
+ uncheckedValue,
+ checkedValue
+ } = this.props;
+
+ this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
+ }
+
+ toggleChecked = (checked, shiftKey) => {
+ const {
+ name,
+ value,
+ checkedValue,
+ uncheckedValue
+ } = this.props;
+
+ const newValue = checked ? checkedValue : uncheckedValue;
+
+ if (value !== newValue) {
+ this.props.onChange({
+ name,
+ value: newValue,
+ shiftKey
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ setRef = (ref) => {
+ this._checkbox = ref;
+ }
+
+ onClick = (event) => {
+ const shiftKey = event.nativeEvent.shiftKey;
+ const checked = !this._checkbox.checked;
+
+ event.preventDefault();
+ this.toggleChecked(checked, shiftKey);
+ }
+
+ onChange = (event) => {
+ const checked = event.target.checked;
+ const shiftKey = event.nativeEvent.shiftKey;
+
+ this.toggleChecked(checked, shiftKey);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ containerClassName,
+ name,
+ value,
+ checkedValue,
+ uncheckedValue,
+ helpText,
+ helpTextWarning,
+ isDisabled,
+ kind
+ } = this.props;
+
+ const isChecked = value === checkedValue;
+ const isUnchecked = value === uncheckedValue;
+ const isIndeterminate = !isChecked && !isUnchecked;
+ const isCheckClass = `${kind}IsChecked`;
+
+ return (
+
+
+
+
+
+ {
+ 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/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css
new file mode 100644
index 000000000..4662cc581
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css
@@ -0,0 +1,66 @@
+.tether {
+ z-index: 2000;
+}
+
+.enhancedSelect {
+ composes: input from 'Components/Form/Input.css';
+ composes: link from 'Components/Link/Link.css';
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ color: $black;
+ cursor: default;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.isDisabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.dropdownArrowContainer {
+ margin-left: 12px;
+}
+
+.optionsContainer {
+ width: auto;
+}
+
+.options {
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
+
+.optionsModal {
+ display: flex;
+ justify-content: center;
+ max-width: 90%;
+ width: 350px !important;
+ height: auto !important;
+}
+
+.optionsInnerModalBody {
+ composes: innerModalBody from 'Components/Modal/ModalBody.css';
+
+ padding: 0;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
new file mode 100644
index 000000000..76383f749
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -0,0 +1,399 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import EnhancedSelectInputOption from './EnhancedSelectInputOption';
+import styles from './EnhancedSelectInput.css';
+
+const tetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ],
+ attachment: 'top left',
+ targetAttachment: 'bottom left'
+};
+
+function isArrowKey(keyCode) {
+ return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
+}
+
+function getSelectedOption(selectedIndex, values) {
+ return values[selectedIndex];
+}
+
+function findIndex(startingIndex, direction, values) {
+ let indexToTest = startingIndex + direction;
+
+ while (indexToTest !== startingIndex) {
+ if (indexToTest < 0) {
+ indexToTest = values.length - 1;
+ } else if (indexToTest >= values.length) {
+ indexToTest = 0;
+ }
+
+ if (getSelectedOption(indexToTest, values).isDisabled) {
+ indexToTest = indexToTest + direction;
+ } else {
+ return indexToTest;
+ }
+ }
+}
+
+function previousIndex(selectedIndex, values) {
+ return findIndex(selectedIndex, -1, values);
+}
+
+function nextIndex(selectedIndex, values) {
+ return findIndex(selectedIndex, 1, values);
+}
+
+function getSelectedIndex(props) {
+ const {
+ value,
+ values
+ } = props;
+
+ return values.findIndex((v) => {
+ return v.key === value;
+ });
+}
+
+function getKey(selectedIndex, values) {
+ return values[selectedIndex].key;
+}
+
+class EnhancedSelectInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false,
+ selectedIndex: getSelectedIndex(props),
+ width: 0,
+ isMobile: isMobileUtil()
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.value !== this.props.value) {
+ this.setState({
+ selectedIndex: getSelectedIndex(this.props)
+ });
+ }
+ }
+
+ //
+ // Control
+
+ _setButtonRef = (ref) => {
+ this._buttonRef = ref;
+ }
+
+ _setOptionsRef = (ref) => {
+ this._optionsRef = ref;
+ }
+
+ _addListener() {
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const button = ReactDOM.findDOMNode(this._buttonRef);
+ const options = ReactDOM.findDOMNode(this._optionsRef);
+
+ if (!button || this.state.isMobile) {
+ return;
+ }
+
+ if (
+ !button.contains(event.target) &&
+ options &&
+ !options.contains(event.target) &&
+ this.state.isOpen
+ ) {
+ this.setState({ isOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onBlur = () => {
+ this.setState({
+ selectedIndex: getSelectedIndex(this.props)
+ });
+ }
+
+ onKeyDown = (event) => {
+ const {
+ values
+ } = this.props;
+
+ const {
+ isOpen,
+ selectedIndex
+ } = this.state;
+
+ const keyCode = event.keyCode;
+ const newState = {};
+
+ if (!isOpen) {
+ if (isArrowKey(keyCode)) {
+ event.preventDefault();
+ newState.isOpen = true;
+ }
+
+ if (
+ selectedIndex == null ||
+ getSelectedOption(selectedIndex, values).isDisabled
+ ) {
+ if (keyCode === keyCodes.UP_ARROW) {
+ newState.selectedIndex = previousIndex(0, values);
+ } else if (keyCode === keyCodes.DOWN_ARROW) {
+ newState.selectedIndex = nextIndex(values.length - 1, values);
+ }
+ }
+
+ this.setState(newState);
+ return;
+ }
+
+ if (keyCode === keyCodes.UP_ARROW) {
+ event.preventDefault();
+ newState.selectedIndex = previousIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.DOWN_ARROW) {
+ event.preventDefault();
+ newState.selectedIndex = nextIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.ENTER) {
+ event.preventDefault();
+ newState.isOpen = false;
+ this.onSelect(getKey(selectedIndex, values));
+ }
+
+ if (keyCode === keyCodes.TAB) {
+ newState.isOpen = false;
+ this.onSelect(getKey(selectedIndex, values));
+ }
+
+ if (keyCode === keyCodes.ESCAPE) {
+ event.preventDefault();
+ event.stopPropagation();
+ newState.isOpen = false;
+ newState.selectedIndex = getSelectedIndex(this.props);
+ }
+
+ if (!_.isEmpty(newState)) {
+ this.setState(newState);
+ }
+ }
+
+ onPress = () => {
+ if (this.state.isOpen) {
+ this._removeListener();
+ } else {
+ this._addListener();
+ }
+
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onSelect = (value) => {
+ this.setState({ isOpen: false });
+
+ this.props.onChange({
+ name: this.props.name,
+ value
+ });
+ }
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ }
+
+ onOptionsModalClose = () => {
+ this.setState({ isOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ disabledClassName,
+ values,
+ isDisabled,
+ hasError,
+ hasWarning,
+ selectedValueOptions,
+ selectedValueComponent: SelectedValueComponent,
+ optionComponent: OptionComponent
+ } = this.props;
+
+ const {
+ selectedIndex,
+ width,
+ isOpen,
+ isMobile
+ } = this.state;
+
+ const selectedOption = getSelectedOption(selectedIndex, values);
+
+ return (
+
+
+
+
+
+ {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..dedf7beaa
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css
@@ -0,0 +1,37 @@
+.option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 5px 10px;
+ width: 100%;
+ cursor: default;
+
+ &:hover {
+ background-color: #f9f9f9;
+ }
+}
+
+.isSelected {
+ background-color: #e2e2e2;
+
+ &.isMobile {
+ background-color: inherit;
+
+ .iconContainer {
+ color: $primaryColor;
+ }
+ }
+}
+
+.isDisabled {
+ background-color: #aaa;
+}
+
+.isMobile {
+ height: 50px;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-child {
+ border: none;
+ }
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
new file mode 100644
index 000000000..a1a161c79
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './EnhancedSelectInputOption.css';
+
+class EnhancedSelectInputOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ id,
+ onSelect
+ } = this.props;
+
+ onSelect(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ isSelected,
+ isDisabled,
+ isMobile,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ {
+ isMobile &&
+
+
+
+ }
+
+ );
+ }
+}
+
+EnhancedSelectInputOption.propTypes = {
+ className: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ onSelect: PropTypes.func.isRequired
+};
+
+EnhancedSelectInputOption.defaultProps = {
+ className: styles.option,
+ isDisabled: false
+};
+
+export default EnhancedSelectInputOption;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
new file mode 100644
index 000000000..aab9f1b7d
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
@@ -0,0 +1,3 @@
+.selectedValue {
+ flex: 1 1 auto;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
new file mode 100644
index 000000000..2343fedc2
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './EnhancedSelectInputSelectedValue.css';
+
+function EnhancedSelectInputSelectedValue(props) {
+ const {
+ className,
+ children
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+EnhancedSelectInputSelectedValue.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node
+};
+
+EnhancedSelectInputSelectedValue.defaultProps = {
+ className: styles.selectedValue
+};
+
+export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Form.css b/frontend/src/Components/Form/Form.css
new file mode 100644
index 000000000..987b1a0a1
--- /dev/null
+++ b/frontend/src/Components/Form/Form.css
@@ -0,0 +1,11 @@
+.form {
+
+}
+
+.error {
+ color: $dangerColor;
+}
+
+.warning {
+ color: $warningColor;
+}
diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js
new file mode 100644
index 000000000..9a3579b45
--- /dev/null
+++ b/frontend/src/Components/Form/Form.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './Form.css';
+
+function Form({ children, validationErrors, validationWarnings, ...otherProps }) {
+ return (
+
+
+ {
+ 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..41a56eff0
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.css
@@ -0,0 +1,24 @@
+.group {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+/* Sizes */
+
+.small {
+ max-width: $formGroupSmallWidth;
+}
+
+.medium {
+ max-width: $formGroupMediumWidth;
+}
+
+.large {
+ max-width: $formGroupLargeWidth;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .group {
+ display: block;
+ }
+}
diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js
new file mode 100644
index 000000000..edec4b86d
--- /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.string.isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormGroup.defaultProps = {
+ className: styles.group,
+ size: sizes.SMALL,
+ advancedSettings: false,
+ isAdvanced: false
+};
+
+export default FormGroup;
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..ef7a29809
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.css
@@ -0,0 +1,30 @@
+.inputGroupContainer {
+ flex: 1 1 auto;
+}
+
+.inputGroup {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+}
+
+.inputContainer {
+ flex: 1 1 auto;
+}
+
+.pendingChangesContainer {
+ display: flex;
+ justify-content: flex-end;
+ width: 30px;
+}
+
+.pendingChangesIcon {
+ color: $warningColor;
+ font-size: 20px;
+ line-height: 35px;
+}
+
+.helpLink {
+ margin-top: 5px;
+ line-height: 20px;
+}
diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js
new file mode 100644
index 000000000..38bde4fa0
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -0,0 +1,235 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, inputTypes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import CaptchaInputConnector from './CaptchaInputConnector';
+import CheckInput from './CheckInput';
+import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput';
+import NumberInput from './NumberInput';
+import OAuthInputConnector from './OAuthInputConnector';
+import PasswordInput from './PasswordInput';
+import PathInputConnector from './PathInputConnector';
+import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
+import LanguageProfileSelectInputConnector from './LanguageProfileSelectInputConnector';
+import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
+import SeriesTypeSelectInput from './SeriesTypeSelectInput';
+import SelectInput from './SelectInput';
+import TagInputConnector from './TagInputConnector';
+import TextTagInputConnector from './TextTagInputConnector';
+import TextInput from './TextInput';
+import FormInputHelpText from './FormInputHelpText';
+import styles from './FormInputGroup.css';
+
+function getComponent(type) {
+ switch (type) {
+ case inputTypes.CAPTCHA:
+ return CaptchaInputConnector;
+
+ case inputTypes.CHECK:
+ return CheckInput;
+
+ case inputTypes.MONITOR_EPISODES_SELECT:
+ return MonitorEpisodesSelectInput;
+
+ case inputTypes.NUMBER:
+ return NumberInput;
+
+ case inputTypes.OAUTH:
+ return OAuthInputConnector;
+
+ case inputTypes.PASSWORD:
+ return PasswordInput;
+
+ case inputTypes.PATH:
+ return PathInputConnector;
+
+ case inputTypes.QUALITY_PROFILE_SELECT:
+ return QualityProfileSelectInputConnector;
+
+ case inputTypes.LANGUAGE_PROFILE_SELECT:
+ return LanguageProfileSelectInputConnector;
+
+ case inputTypes.ROOT_FOLDER_SELECT:
+ return RootFolderSelectInputConnector;
+
+ case inputTypes.SELECT:
+ return SelectInput;
+
+ case inputTypes.SERIES_TYPE_SELECT:
+ return SeriesTypeSelectInput;
+
+ case inputTypes.TAG:
+ return TagInputConnector;
+
+ case inputTypes.TEXT_TAG:
+ return TextTagInputConnector;
+
+ default:
+ return TextInput;
+ }
+}
+
+function FormInputGroup(props) {
+ const {
+ className,
+ containerClassName,
+ inputClassName,
+ type,
+ buttons,
+ helpText,
+ helpTexts,
+ helpTextWarning,
+ helpLink,
+ pending,
+ errors,
+ warnings,
+ ...otherProps
+ } = props;
+
+ const InputComponent = getComponent(type);
+ const checkInput = type === inputTypes.CHECK;
+ const hasError = !!errors.length;
+ const hasWarning = !hasError && !!warnings.length;
+ const buttonsArray = React.Children.toArray(buttons);
+ const lastButtonIndex = buttonsArray.length - 1;
+ const hasButton = !!buttonsArray.length;
+
+ return (
+
+
+
+
+
+
+ {
+ 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,
+ 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..327bd7283
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './FormInputHelpText.css';
+
+function FormInputHelpText(props) {
+ const {
+ className,
+ text,
+ link,
+ linkTooltip,
+ isError,
+ isWarning,
+ isCheckInput
+ } = props;
+
+ return (
+
+ {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..ad175f202
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.css
@@ -0,0 +1,22 @@
+.label {
+ display: flex;
+ justify-content: flex-end;
+ flex: 0 0 $formLabelWidth;
+ margin-right: $formLabelRightMarginWidth;
+ font-weight: bold;
+ line-height: 35px;
+}
+
+.hasError {
+ color: $dangerColor;
+}
+
+.isAdvanced {
+ color: $advancedFormLabelColor;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .label {
+ justify-content: flex-start;
+ }
+}
diff --git a/frontend/src/Components/Form/FormLabel.js b/frontend/src/Components/Form/FormLabel.js
new file mode 100644
index 000000000..dca800826
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './FormLabel.css';
+
+function FormLabel({
+ children,
+ className,
+ errorClassName,
+ name,
+ hasError,
+ isAdvanced,
+ ...otherProps
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+FormLabel.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+ errorClassName: PropTypes.string,
+ name: PropTypes.string,
+ hasError: PropTypes.bool,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormLabel.defaultProps = {
+ className: styles.label,
+ errorClassName: styles.hasError,
+ isAdvanced: false
+};
+
+export default FormLabel;
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/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..d981d44f7
--- /dev/null
+++ b/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectInput from './SelectInput';
+
+const monitorOptions = [
+ { key: 'all', value: 'All Episodes' },
+ { key: 'future', value: 'Future Episodes' },
+ { key: 'missing', value: 'Missing Episodes' },
+ { key: 'existing', value: 'Existing Episodes' },
+ { key: 'first', value: 'Only First Season' },
+ { key: 'latest', value: 'Only Latest Season' },
+ { key: 'none', value: 'None' }
+];
+
+function MonitorEpisodesSelectInput(props) {
+ const {
+ includeNoChange,
+ includeMixed,
+ ...otherProps
+ } = props;
+
+ const values = [...monitorOptions];
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return (
+
+ );
+}
+
+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..b760c0a72
--- /dev/null
+++ b/frontend/src/Components/Form/NumberInput.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextInput from './TextInput';
+
+class NumberInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ let newValue = null;
+
+ if (value) {
+ newValue = this.props.isFloat ? parseFloat(value) : parseInt(value);
+ }
+
+ this.props.onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+NumberInput.propTypes = {
+ value: 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..ddbc1e310
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInput.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+
+function OAuthInput(props) {
+ const {
+ authorizing,
+ onPress
+ } = props;
+
+ return (
+
+
+ Start OAuth
+
+
+ );
+}
+
+OAuthInput.propTypes = {
+ authorizing: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+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..6e9ad110c
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInputConnector.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { startOAuth, resetOAuth } from 'Store/Actions/oAuthActions';
+import OAuthInput from './OAuthInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.oAuth,
+ (oAuth) => {
+ return oAuth;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ startOAuth,
+ resetOAuth
+};
+
+class OAuthInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ accessToken,
+ accessTokenSecret,
+ onChange
+ } = this.props;
+
+ if (accessToken &&
+ accessToken !== prevProps.accessToken &&
+ accessTokenSecret &&
+ accessTokenSecret !== prevProps.accessTokenSecret) {
+ onChange({ name: 'AccessToken', value: accessToken });
+ onChange({ name: 'AccessTokenSecret', value: accessTokenSecret });
+ }
+ }
+
+ componentWillUnmount = () => {
+ this.props.resetOAuth();
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ provider,
+ providerData
+ } = this.props;
+
+ this.props.startOAuth({ provider, providerData });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OAuthInputConnector.propTypes = {
+ accessToken: PropTypes.string,
+ accessTokenSecret: PropTypes.string,
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ onChange: PropTypes.func.isRequired,
+ startOAuth: PropTypes.func.isRequired,
+ resetOAuth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);
diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js
new file mode 100644
index 000000000..2560ce3c2
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import TextInput from './TextInput';
+
+function PasswordInput(props) {
+ return (
+
+ );
+}
+
+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..fd6128544
--- /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 {
+ composes: scrollbar from 'Styles/Mixins/scroller.css';
+ composes: scrollbarTrack from 'Styles/Mixins/scroller.css';
+ composes: scrollbarThumb from 'Styles/Mixins/scroller.css';
+}
+
+.pathInputContainerOpen {
+ .pathContainer {
+ position: absolute;
+ z-index: 1;
+ overflow-y: auto;
+ max-height: 200px;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ }
+}
+
+.pathList {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.pathListItem {
+ padding: 0 16px;
+}
+
+.pathMatch {
+ font-weight: bold;
+}
+
+.pathHighlighted {
+ background-color: $menuItemHoverColor;
+}
+
+.fileBrowserButton {
+ composes: button from './FormInputButton.css';
+
+ height: 35px;
+}
diff --git a/frontend/src/Components/Form/PathInput.js b/frontend/src/Components/Form/PathInput.js
new file mode 100644
index 000000000..19ae9f94f
--- /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];
+
+ this.props.onChange({
+ name: this.props.name,
+ value: path.path
+ });
+
+ if (path.type !== 'file') {
+ this.props.onFetchPaths(path.path);
+ }
+ }
+ }
+
+ onInputBlur = () => {
+ this.props.onClearPaths();
+ this.props.onFetchPaths('');
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ this.props.onFetchPaths(value);
+ }
+
+ onSuggestionsClearRequested = () => {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+ // `onInputBlur` will handle clearing when the user leaves the input.
+ }
+
+ onSuggestionSelected = (event, { suggestionValue }) => {
+ this.props.onFetchPaths(suggestionValue);
+ }
+
+ onFileBrowserOpenPress = () => {
+ this.setState({ isFileBrowserModalOpen: true });
+ }
+
+ onFileBrowserModalClose = () => {
+ this.setState({ isFileBrowserModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ inputClassName,
+ name,
+ value,
+ placeholder,
+ paths,
+ hasError,
+ hasWarning,
+ hasFileBrowser,
+ onChange
+ } = this.props;
+
+ const inputProps = {
+ className: classNames(
+ inputClassName,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning,
+ hasFileBrowser && styles.hasFileBrowser
+ ),
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onInputChange,
+ onKeyDown: this.onInputKeyDown,
+ onBlur: this.onInputBlur
+ };
+
+ const theme = {
+ container: styles.pathInputContainer,
+ containerOpen: styles.pathInputContainerOpen,
+ suggestionsContainer: styles.pathContainer,
+ suggestionsList: styles.pathList,
+ suggestion: styles.pathListItem,
+ suggestionHighlighted: styles.pathHighlighted
+ };
+
+ return (
+
+
+
+ {
+ 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..c7b422251
--- /dev/null
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -0,0 +1,126 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function getType(type) {
+ // Textbox,
+ // Password,
+ // Checkbox,
+ // Select,
+ // Path,
+ // FilePath,
+ // Hidden,
+ // Tag,
+ // Action,
+ // Url,
+ // Captcha
+ // OAuth
+
+ switch (type) {
+ case 'captcha':
+ return inputTypes.CAPTCHA;
+ case 'checkbox':
+ return inputTypes.CHECK;
+ case 'password':
+ return inputTypes.PASSWORD;
+ case 'path':
+ return inputTypes.PATH;
+ case 'select':
+ return inputTypes.SELECT;
+ case 'textbox':
+ return inputTypes.TEXT;
+ case 'oauth':
+ return inputTypes.OAUTH;
+ default:
+ return inputTypes.TEXT;
+ }
+}
+
+function getSelectValues(selectOptions) {
+ if (!selectOptions) {
+ return;
+ }
+
+ return _.reduce(selectOptions, (result, option) => {
+ result.push({
+ key: option.value,
+ value: option.name
+ });
+
+ return result;
+ }, []);
+}
+
+function ProviderFieldFormGroup(props) {
+ const {
+ advancedSettings,
+ name,
+ label,
+ helpText,
+ helpLink,
+ value,
+ type,
+ advanced,
+ pending,
+ errors,
+ warnings,
+ selectOptions,
+ onChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {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..ab42c4343
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInput.js
@@ -0,0 +1,105 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import EnhancedSelectInput from './EnhancedSelectInput';
+import RootFolderSelectInputOption from './RootFolderSelectInputOption';
+import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
+
+class RootFolderSelectInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNewRootFolderModalOpen: false,
+ newRootFolderPath: ''
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ name,
+ values,
+ isSaving,
+ saveError,
+ onChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving &&
+ !isSaving &&
+ !saveError &&
+ values.length - prevProps.values.length === 1
+ ) {
+ const newRootFolderPath = this.state.newRootFolderPath;
+
+ onChange({ name, value: newRootFolderPath });
+ this.setState({ newRootFolderPath: '' });
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ if (value === 'addNew') {
+ this.setState({ isAddNewRootFolderModalOpen: true });
+ } else {
+ this.props.onChange({ name, value });
+ }
+ }
+
+ onNewRootFolderSelect = ({ value }) => {
+ this.setState({ newRootFolderPath: value }, () => {
+ this.props.onNewRootFolderSelect(value);
+ });
+ }
+
+ onAddRootFolderModalClose = () => {
+ this.setState({ isAddNewRootFolderModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ includeNoChange,
+ onNewRootFolderSelect,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+RootFolderSelectInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ onChange: PropTypes.func.isRequired,
+ onNewRootFolderSelect: PropTypes.func.isRequired
+};
+
+export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
new file mode 100644
index 000000000..b9ba1a992
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
@@ -0,0 +1,124 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addRootFolder } from 'Store/Actions/rootFolderActions';
+import RootFolderSelectInput from './RootFolderSelectInput';
+
+const ADD_NEW_KEY = 'addNew';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ (state, { includeNoChange }) => includeNoChange,
+ (rootFolders, includeNoChange) => {
+ const values = _.map(rootFolders.items, (rootFolder) => {
+ return {
+ key: rootFolder.path,
+ value: rootFolder.path,
+ freeSpace: rootFolder.freeSpace
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ isDisabled: true
+ });
+ }
+
+ if (!values.length) {
+ values.push({
+ key: '',
+ value: '',
+ isDisabled: true
+ });
+ }
+
+ values.push({
+ key: ADD_NEW_KEY,
+ value: 'Add a new path'
+ });
+
+ return {
+ values,
+ isSaving: rootFolders.isSaving,
+ saveError: rootFolders.saveError
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchAddRootFolder(path) {
+ dispatch(addRootFolder({ path }));
+ }
+ };
+}
+
+class RootFolderSelectInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ values,
+ onChange
+ } = this.props;
+
+ if (!value || !_.some(values, (v) => v.hasOwnProperty(value)) || value === ADD_NEW_KEY) {
+ const defaultValue = values[0];
+
+ if (defaultValue.key === ADD_NEW_KEY) {
+ onChange({ name, value: '' });
+ } else {
+ onChange({ name, value: defaultValue.key });
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onNewRootFolderSelect = (path) => {
+ this.props.dispatchAddRootFolder(path);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchAddRootFolder,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+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..061587119
--- /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: $gray;
+ 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..c3d6ef16d
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import formatBytes from 'Utilities/Number/formatBytes';
+import EnhancedSelectInputOption from './EnhancedSelectInputOption';
+import styles from './RootFolderSelectInputOption.css';
+
+function RootFolderSelectInputOption(props) {
+ const {
+ value,
+ freeSpace,
+ isMobile,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
{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..1f1683dc6
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
@@ -0,0 +1,24 @@
+.selectedValue {
+ composes: selectedValue from './EnhancedSelectInputSelectedValue.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.path {
+ composes: truncate from 'Styles/mixins/truncate.css';
+
+ flex: 1 0 0;
+}
+
+.freeSpace {
+ composes: truncate from 'Styles/mixins/truncate.css';
+
+ flex: 1 0 0;
+ margin-left: 15px;
+ color: $gray;
+ text-align: right;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
new file mode 100644
index 000000000..ef91d979e
--- /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.isRequired,
+ 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..79218f54f
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './SelectInput.css';
+
+class SelectInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: event.target.value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ disabledClassName,
+ name,
+ value,
+ values,
+ isDisabled,
+ hasError,
+ hasWarning
+ } = this.props;
+
+ return (
+
+ {
+ 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,
+ onChange: PropTypes.func.isRequired
+};
+
+SelectInput.defaultProps = {
+ className: styles.select,
+ disabledClassName: styles.isDisabled,
+ isDisabled: 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..87853ccfb
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.css
@@ -0,0 +1,97 @@
+.container {
+ composes: input from 'Components/Form/Input.css';
+
+ display: flex;
+ flex-wrap: wrap;
+ min-height: 35px;
+ height: auto;
+}
+
+.containerFocused {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+}
+
+.selectedTagContainer {
+ flex: 0 0 auto;
+}
+
+.selectedTag {
+ composes: label from 'Components/Label.css';
+
+ border-style: none;
+ font-size: 13px;
+}
+
+/* Selected Tag Kinds */
+
+.info {
+ composes: info from 'Components/Label.css';
+}
+
+.success {
+ composes: success from 'Components/Label.css';
+}
+
+.warning {
+ composes: warning from 'Components/Label.css';
+}
+
+.danger {
+ composes: danger from 'Components/Label.css';
+}
+
+.searchInputContainer {
+ position: relative;
+ flex: 1 0 100px;
+ margin-top: 1px;
+ padding-left: 5px;
+}
+
+.searchInput {
+ max-width: 100%;
+ font-size: 13px;
+
+ input {
+ margin: 0;
+ padding: 0;
+ max-width: 100%;
+ outline: none;
+ border: 0;
+ }
+}
+
+.suggestions {
+ position: absolute;
+ z-index: 1;
+ overflow-y: auto;
+ max-height: 200px;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+
+ ul {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+ }
+
+ li {
+ padding: 0 16px;
+ }
+
+ li mark {
+ font-weight: bold;
+ }
+
+ li:hover {
+ background-color: $menuItemHoverColor;
+ }
+}
+
+.suggestionActive {
+ background-color: $menuItemHoverColor;
+}
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
new file mode 100644
index 000000000..3ad360308
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.js
@@ -0,0 +1,126 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactTags from 'react-tag-autocomplete';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import styles from './TagInput.css';
+
+class TagInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._tagsRef = null;
+ this._inputRef = null;
+ }
+
+ //
+ // Control
+
+ _setTagsRef = (ref) => {
+ this._tagsRef = ref;
+
+ if (ref) {
+ this._inputRef = this._tagsRef.input.input;
+
+ this._inputRef.addEventListener('blur', this.onInputBlur);
+ } else if (this._inputRef) {
+ this._inputRef.removeEventListener('blur', this.onInputBlur);
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputBlur = () => {
+ if (!this._tagsRef) {
+ return;
+ }
+
+ const {
+ tagList,
+ allowNew
+ } = this.props;
+
+ const query = this._tagsRef.state.query.trim();
+
+ if (query) {
+ const existingTag = _.find(tagList, { name: query });
+
+ if (existingTag) {
+ this._tagsRef.addTag(existingTag);
+ } else if (allowNew) {
+ this._tagsRef.addTag({ name: query });
+ }
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ tags,
+ tagList,
+ allowNew,
+ kind,
+ placeholder,
+ onTagAdd,
+ onTagDelete
+ } = this.props;
+
+ const tagInputClassNames = {
+ root: styles.container,
+ rootFocused: styles.containerFocused,
+ selected: styles.selectedTagContainer,
+ selectedTag: classNames(styles.selectedTag, styles[kind]),
+ search: styles.searchInputContainer,
+ searchInput: styles.searchInput,
+ suggestions: styles.suggestions,
+ suggestionActive: styles.suggestionActive,
+ suggestionDisabled: styles.suggestionDisabled
+ };
+
+ return (
+
+ );
+ }
+}
+
+const tagShape = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+TagInput.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ allowNew: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ placeholder: PropTypes.string.isRequired,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired
+};
+
+TagInput.defaultProps = {
+ allowNew: true,
+ kind: kinds.INFO,
+ placeholder: ''
+};
+
+export default TagInput;
diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js
new file mode 100644
index 000000000..163b36895
--- /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/TextInput.css b/frontend/src/Components/Form/TextInput.css
new file mode 100644
index 000000000..25278adbc
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.css
@@ -0,0 +1,19 @@
+.text {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.readOnly {
+ background-color: #eee;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.hasButton {
+ composes: hasButton from 'Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js
new file mode 100644
index 000000000..67c7776bf
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './TextInput.css';
+
+class TextInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: event.target.value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ type,
+ readOnly,
+ autoFocus,
+ placeholder,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ hasButton,
+ onFocus
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+TextInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+ readOnly: PropTypes.bool,
+ autoFocus: PropTypes.bool,
+ placeholder: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ hasButton: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onFocus: PropTypes.func
+};
+
+TextInput.defaultProps = {
+ className: styles.text,
+ type: 'text',
+ readOnly: false,
+ autoFocus: false,
+ value: ''
+};
+
+export default TextInput;
diff --git a/frontend/src/Components/Form/TextTagInput.js b/frontend/src/Components/Form/TextTagInput.js
new file mode 100644
index 000000000..ae9d35baa
--- /dev/null
+++ b/frontend/src/Components/Form/TextTagInput.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactTags from 'react-tag-autocomplete';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import styles from './TagInput.css';
+
+class TextTagInput extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ tags,
+ allowNew,
+ kind,
+ placeholder,
+ onTagAdd,
+ onTagDelete
+ } = this.props;
+
+ const tagInputClassNames = {
+ root: styles.container,
+ rootFocused: styles.containerFocused,
+ selected: styles.selectedTagContainer,
+ selectedTag: classNames(styles.selectedTag, styles[kind]),
+ search: styles.searchInputContainer,
+ searchInput: styles.searchInput,
+ suggestions: styles.suggestions,
+ suggestionActive: styles.suggestionActive,
+ suggestionDisabled: styles.suggestionDisabled
+ };
+
+ return (
+
+ );
+ }
+}
+
+const tagShape = {
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+TextTagInput.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ allowNew: PropTypes.bool.isRequired,
+ kind: PropTypes.string.isRequired,
+ placeholder: PropTypes.string,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired
+};
+
+TextTagInput.defaultProps = {
+ allowNew: true,
+ kind: kinds.INFO
+};
+
+export default TextTagInput;
diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js
new file mode 100644
index 000000000..2c9e52cbc
--- /dev/null
+++ b/frontend/src/Components/Form/TextTagInputConnector.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 split from 'Utilities/String/split';
+import TextTagInput from './TextTagInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ (tags) => {
+ return {
+ tags: split(tags).reduce((result, tag) => {
+ if (tag) {
+ result.push({
+ id: tag,
+ name: tag
+ });
+ }
+
+ return result;
+ }, [])
+ };
+ }
+ );
+}
+
+class TextTagInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = split(value);
+ newValue.push(tag.name);
+
+ this.props.onChange({ name, value: newValue.join(',') });
+ }
+
+ onTagDelete = (index) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = split(value);
+ newValue.splice(index, 1);
+
+ this.props.onChange({
+ name,
+ value: newValue.join(',')
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TextTagInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ 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..4298ea38f
--- /dev/null
+++ b/frontend/src/Components/Icon.css
@@ -0,0 +1,15 @@
+.danger {
+ color: $dangerColor;
+}
+
+.default {
+ color: inherit;
+}
+
+.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..d8b17d095
--- /dev/null
+++ b/frontend/src/Components/Icon.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import classNames from 'classnames';
+import styles from './Icon.css';
+
+function Icon(props) {
+ const {
+ className,
+ name,
+ kind,
+ size,
+ title
+ } = props;
+
+ return (
+
+
+ );
+}
+
+Icon.propTypes = {
+ className: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ kind: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ title: PropTypes.string
+};
+
+Icon.defaultProps = {
+ kind: kinds.DEFAULT,
+ size: 14
+};
+
+export default Icon;
diff --git a/frontend/src/Components/Label.css b/frontend/src/Components/Label.css
new file mode 100644
index 000000000..62f4af6c2
--- /dev/null
+++ b/frontend/src/Components/Label.css
@@ -0,0 +1,102 @@
+.label {
+ display: inline-block;
+ margin: 2px;
+ border: 1px solid;
+ border-radius: 2px;
+ color: $white;
+ text-align: center;
+ white-space: nowrap;
+ font-weight: bold;
+ line-height: 1;
+ cursor: default;
+}
+
+/** Kinds **/
+
+.danger {
+ border-color: $dangerColor;
+ background-color: $dangerColor;
+
+ &.outline {
+ color: $dangerColor;
+ }
+}
+
+.default {
+ border-color: $themeLightColor;
+ background-color: $themeLightColor;
+
+ &.outline {
+ color: $themeLightColor;
+ }
+}
+
+.info {
+ border-color: $infoColor;
+ background-color: $infoColor;
+
+ &.outline {
+ color: $infoColor;
+ }
+}
+
+.inverse {
+ border-color: $gray;
+ background-color: $gray;
+ color: $defaultColor;
+
+ &.outline {
+ background-color: $defaultColor !important;
+ color: $gray;
+ }
+}
+
+.primary {
+ border-color: $primaryColor;
+ background-color: $primaryColor;
+
+ &.outline {
+ color: $primaryColor;
+ }
+}
+
+.success {
+ border-color: $successColor;
+ background-color: $successColor;
+
+ &.outline {
+ color: $successColor;
+ }
+}
+
+.warning {
+ border-color: $warningColor;
+ background-color: $warningColor;
+
+ &.outline {
+ color: $warningColor;
+ }
+}
+
+/** Sizes **/
+
+.small {
+ padding: 1px 3px;
+ font-size: 11px;
+}
+
+.medium {
+ padding: 2px 5px;
+ font-size: 12px;
+}
+
+.large {
+ padding: 3px 7px;
+ font-size: 14px;
+}
+
+/** Outline **/
+
+.outline {
+ background-color: $white;
+}
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..df78d0473
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.js
@@ -0,0 +1,128 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Clipboard from 'Clipboard';
+import { icons, kinds } from 'Helpers/Props';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import Icon from 'Components/Icon';
+import FormInputButton from 'Components/Form/FormInputButton';
+import styles from './ClipboardButton.css';
+
+class ClipboardButton extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._id = getUniqueElememtId();
+ this._successTimeout = null;
+
+ this.state = {
+ showSuccess: false,
+ showError: false
+ };
+ }
+
+ componentDidMount() {
+ this._clipboard = new Clipboard(`#${this._id}`, {
+ text: () => this.props.value
+ });
+
+ this._clipboard.on('success', this.onSuccess);
+ }
+
+ componentDidUpdate() {
+ const {
+ showSuccess,
+ showError
+ } = this.state;
+
+ if (showSuccess || showError) {
+ this._testResultTimeout = setTimeout(this.resetState, 3000);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._clipboard) {
+ this._clipboard.destroy();
+ }
+ }
+
+ //
+ // Control
+
+ resetState = () => {
+ this.setState({
+ showSuccess: false,
+ showError: false
+ });
+ }
+
+ //
+ // Listeners
+
+ onSuccess = () => {
+ this.setState({
+ showSuccess: true
+ });
+ }
+
+ onError = () => {
+ this.setState({
+ showError: true
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ ...otherProps
+ } = this.props;
+
+
+ const {
+ showSuccess,
+ showError
+ } = this.state;
+
+ const showStateIcon = showSuccess || showError;
+ const iconName = showError ? icons.DANGER : icons.CHECK;
+ const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
+
+ return (
+
+
+ {
+ 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..ba13af8d4
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.css
@@ -0,0 +1,16 @@
+.button {
+ composes: link from 'Components/Link/Link.css';
+
+ margin: 0 2px;
+ width: 22px;
+ border-radius: 4px;
+ background-color: transparent;
+ text-align: center;
+ font-size: inherit;
+
+ &:hover {
+ border: none;
+ background-color: inherit;
+ color: $iconButtonHoverColor;
+ }
+}
diff --git a/frontend/src/Components/Link/IconButton.js b/frontend/src/Components/Link/IconButton.js
new file mode 100644
index 000000000..3751530cc
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import Link from './Link';
+import styles from './IconButton.css';
+
+function IconButton(props) {
+ const {
+ className,
+ iconClassName,
+ name,
+ kind,
+ size,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+IconButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ iconClassName: PropTypes.string,
+ kind: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ size: PropTypes.number
+};
+
+IconButton.defaultProps = {
+ className: styles.button
+};
+
+export default IconButton;
diff --git a/frontend/src/Components/Link/Link.css b/frontend/src/Components/Link/Link.css
new file mode 100644
index 000000000..cd760cdae
--- /dev/null
+++ b/frontend/src/Components/Link/Link.css
@@ -0,0 +1,25 @@
+.link {
+ margin: 0;
+ padding: 0;
+ outline: none;
+ border: 0;
+ background: none;
+ color: inherit;
+ text-align: inherit;
+ text-decoration: none;
+ cursor: pointer;
+
+ &:global(.isDisabled) {
+ /*color: $disabledColor;*/
+ pointer-events: none;
+ }
+}
+
+.to {
+ color: $linkColor;
+
+ &:hover {
+ color: $linkHoverColor;
+ text-decoration: underline;
+ }
+}
diff --git a/frontend/src/Components/Link/Link.js b/frontend/src/Components/Link/Link.js
new file mode 100644
index 000000000..86701ef20
--- /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..a29ab5625
--- /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%;
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/Components/Link/SpinnerButton.js b/frontend/src/Components/Link/SpinnerButton.js
new file mode 100644
index 000000000..8e5101afe
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerButton.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 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.string.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..87cf55d95
--- /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..8f62d6031
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerIconButton.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from './IconButton';
+
+function SpinnerIconButton(props) {
+ const {
+ name,
+ spinningName,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+}
+
+SpinnerIconButton.propTypes = {
+ name: PropTypes.string.isRequired,
+ spinningName: PropTypes.string.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isSpinning: PropTypes.bool.isRequired
+};
+
+SpinnerIconButton.defaultProps = {
+ spinningName: icons.SPINNER,
+ isDisabled: false,
+ isSpinning: false
+};
+
+export default SpinnerIconButton;
diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css
new file mode 100644
index 000000000..b8288f7f9
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingIndicator.css
@@ -0,0 +1,65 @@
+.loading {
+ margin-top: 20px;
+ text-align: center;
+}
+
+.rippleContainer {
+ position: relative;
+ display: inline-block;
+}
+
+.ripple:nth-child(0) {
+ animation-delay: -0.8s;
+}
+
+.ripple:nth-child(1) {
+ animation-delay: -0.6s;
+}
+
+.ripple:nth-child(2) {
+ animation-delay: -0.4s;
+}
+
+.ripple:nth-child(3) {
+ animation-delay: -0.2s;
+}
+
+.ripple {
+ position: absolute;
+ border: 2px solid #3a3f51;
+ border-radius: 100%;
+ animation-fill-mode: both;
+ animation: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8);
+}
+
+@-webkit-keyframes rippleContainer {
+ 0% {
+ opacity: 1;
+ transform: scale(0.1);
+ }
+
+ 70% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}
+
+@keyframes rippleContainer {
+ 0% {
+ opacity: 1;
+ transform: scale(0.1);
+ }
+
+ 70% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}
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..ab7b2592f
--- /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/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..815176e13
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenu.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Menu from 'Components/Menu/Menu';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+import styles from './FilterMenu.css';
+
+class FilterMenu extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+ );
+ }
+}
+
+FilterMenu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+FilterMenu.defaultProps = {
+ className: styles.filterMenu
+};
+
+export default FilterMenu;
diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js
new file mode 100644
index 000000000..54c293c49
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenuItem.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import SelectedMenuItem from './SelectedMenuItem';
+
+class FilterMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ value,
+ onPress
+ } = this.props;
+
+ onPress(name, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ value,
+ filterKey,
+ filterValue,
+ ...otherProps
+ } = this.props;
+
+ const isSelected = name === filterKey && value === filterValue;
+
+ return (
+
+ );
+ }
+}
+
+FilterMenuItem.propTypes = {
+ name: PropTypes.string,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
+ onPress: PropTypes.func.isRequired
+};
+
+FilterMenuItem.defaultProps = {
+ name: null,
+ value: null
+};
+
+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..06e38dcf7
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.js
@@ -0,0 +1,203 @@
+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();
+ }
+
+ //
+ // 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..04a7439cd
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.css
@@ -0,0 +1,15 @@
+.menuButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+}
diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js
new file mode 100644
index 000000000..d89a52d1d
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './MenuButton.css';
+
+class MenuButton extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ onPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+MenuButton.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ onPress: PropTypes.func
+};
+
+MenuButton.defaultProps = {
+ className: styles.menuButton
+};
+
+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..b2ad5d312
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItem.css
@@ -0,0 +1,19 @@
+.menuItem {
+ composes: truncate from 'Styles/mixins/truncate.css';
+
+ 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/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..bc8f41f5a
--- /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.string.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..2977f661b
--- /dev/null
+++ b/frontend/src/Components/Menu/SortMenu.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Menu from 'Components/Menu/Menu';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+
+class SortMenu extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+ );
+ }
+}
+
+SortMenu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+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..ef08e2843
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.css
@@ -0,0 +1,13 @@
+.menuButton {
+ composes: menuButton from './MenuButton.css';
+
+ width: $toolbarButtonWidth;
+ height: $toolbarHeight;
+ text-align: center;
+}
+
+.label {
+ height: 14px;
+ color: $toolbarLabelColor;
+ font-size: $extraSmallFontSize;
+}
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js
new file mode 100644
index 000000000..33f7802de
--- /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.string.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..61be4f952
--- /dev/null
+++ b/frontend/src/Components/Menu/ViewMenu.js
@@ -0,0 +1,30 @@
+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,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+ViewMenu.propTypes = {
+ children: PropTypes.node.isRequired
+};
+
+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..4872a011e
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.css
@@ -0,0 +1,79 @@
+.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;
+}
+
+@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 {
+ max-height: 100%;
+ width: 100%;
+ height: 100%;
+ }
+}
diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js
new file mode 100644
index 000000000..1696da829
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.js
@@ -0,0 +1,196 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Portal from 'react-portal';
+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 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._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
+
+ _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 modalElement = ReactDOM.findDOMNode(this.refs.modal);
+
+ return !modalElement || !modalElement.contains(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) => {
+ if (this._isBackdropPressed && this._isBackdropTarget(event)) {
+ this.props.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();
+ }
+ }
+ }
+
+ onClosePress = (event) => {
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ backdropClassName,
+ size,
+ children,
+ isOpen
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+Modal.propTypes = {
+ className: PropTypes.string,
+ backdropClassName: PropTypes.string,
+ size: PropTypes.oneOf(sizes.all),
+ children: PropTypes.node,
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+Modal.defaultProps = {
+ className: styles.modal,
+ backdropClassName: styles.modalBackdrop,
+ size: sizes.LARGE
+};
+
+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..2e55a91cb
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalBody.css
@@ -0,0 +1,14 @@
+$modalBodyPadding: 30px;
+
+.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..cc165dda2
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalContent.js
@@ -0,0 +1,46 @@
+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,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ {children}
+
+ );
+}
+
+ModalContent.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node,
+ onModalClose: PropTypes.func.isRequired
+};
+
+ModalContent.defaultProps = {
+ className: styles.modalContent
+};
+
+export default ModalContent;
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..9ebaad61c
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalHeader.css
@@ -0,0 +1,8 @@
+.modalHeader {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ 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..e2c68bed1
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.css
@@ -0,0 +1,13 @@
+.toggleButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ padding: 0;
+ font-size: inherit;
+}
+
+.disabledButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ 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..1190c03a3
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import styles from './MonitorToggleButton.css';
+
+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 monitoredMessage = 'Monitored, click to unmonitor';
+ const unmonitoredMessage = 'Unmonitored, click to monitor';
+ const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
+
+ if (isDisabled) {
+ return (
+
+ );
+ }
+
+ 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..8ef61fecd
--- /dev/null
+++ b/frontend/src/Components/Page/ErrorPage.js
@@ -0,0 +1,52 @@
+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,
+ tagsError,
+ qualityProfilesError,
+ 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 (tagsError) {
+ errorMessage = getErrorMessage(seriesError, 'Failed to load series from API');
+ } else if (qualityProfilesError) {
+ errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality 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,
+ tagsError: PropTypes.object,
+ qualityProfilesError: 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..1c8de74b6
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.css
@@ -0,0 +1,60 @@
+.header {
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ height: $headerHeight;
+ background-color: #00a65b;
+ color: $white;
+}
+
+.logoContainer {
+ display: flex;
+ justify-content: center;
+ flex: 0 0 $sidebarWidth;
+}
+
+.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..9450a43c5
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.js
@@ -0,0 +1,96 @@
+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.openKeyboardShortcutsModal);
+ }
+
+ //
+ // Control
+
+ openKeyboardShortcutsModal = () => {
+ 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..44aa20453
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
@@ -0,0 +1,27 @@
+.menuButton {
+ margin-right: 15px;
+ width: 30px;
+ height: 60px;
+ text-align: center;
+
+ &:hover {
+ color: $themeDarkColor;
+ }
+}
+
+.itemIcon {
+ margin-right: 8px;
+}
+
+.separator {
+ overflow: hidden;
+ height: 1px;
+ background-color: $themeDarkColor;
+}
+
+@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..a261c1b01
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
@@ -0,0 +1,75 @@
+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 styles from './PageHeaderActionsMenu.css';
+
+function PageHeaderActionsMenu(props) {
+ const {
+ formsAuth,
+ onRestartPress,
+ onShutdownPress
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ Restart
+
+
+
+
+ Shutdown
+
+
+ {
+ formsAuth &&
+
+ }
+
+ {
+ formsAuth &&
+
+
+ Logout
+
+ }
+
+
+
+ );
+}
+
+PageHeaderActionsMenu.propTypes = {
+ formsAuth: PropTypes.bool.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..aaf8a5be5
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchInput.css
@@ -0,0 +1,98 @@
+.wrapper {
+ display: flex;
+}
+
+.icon {
+ line-height: 24px !important;
+}
+
+.input {
+ margin-left: 8px;
+ width: 200px;
+ border: none;
+ border-bottom: solid 1px $white;
+ 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 {
+ composes: scrollbar from 'Styles/Mixins/scroller.css';
+ composes: scrollbarTrack from 'Styles/Mixins/scroller.css';
+ composes: scrollbarThumb from 'Styles/Mixins/scroller.css';
+}
+
+.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..9ee608d39
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchInput.js
@@ -0,0 +1,250 @@
+import _ from 'lodash';
+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 }) => {
+ 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 = _.filter(this.props.series, (series) => {
+ // Check the title first and if there isn't a match fallback to the alternate titles
+
+ const titleMatch = jdu.replace(series.title).toLowerCase().contains(lowerCaseValue);
+
+ return titleMatch || _.some(series.alternateTitles, (alternateTitle) => {
+ return jdu.replace(alternateTitle.title).toLowerCase().contains(lowerCaseValue);
+ });
+ });
+
+ this.setState({ suggestions });
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.reset();
+ }
+
+ onSuggestionSelected = (event, { suggestion, sectionIndex }) => {
+ 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
+ });
+ }
+
+ if (suggestions.length <= 3) {
+ 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..83f111e88
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js
@@ -0,0 +1,31 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import SeriesSearchInput from './SeriesSearchInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ createAllSeriesSelector(),
+ (series) => {
+ return {
+ series: _.sortBy(series, 'sortTitle')
+ };
+ }
+ );
+}
+
+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..35dc98df5
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchResult.css
@@ -0,0 +1,34 @@
+.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 {
+ flex: 1 1 1px;
+ margin-left: 5px;
+ color: $disabledColor;
+ font-size: $smallFontSize;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .titles,
+ .title,
+ .alternateTitle {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+ }
+}
diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.js b/frontend/src/Components/Page/Header/SeriesSearchResult.js
new file mode 100644
index 000000000..f612bfa1f
--- /dev/null
+++ b/frontend/src/Components/Page/Header/SeriesSearchResult.js
@@ -0,0 +1,59 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ArtistPoster from 'Artist/ArtistPoster';
+import styles from './SeriesSearchResult.css';
+
+function getMatchingAlternateTile(alternateTitles, query) {
+ return _.first(alternateTitles, (alternateTitle) => {
+ return alternateTitle.title.toLowerCase().contains(query.toLowerCase());
+ });
+}
+
+function SeriesSearchResult(props) {
+ const {
+ query,
+ title,
+ alternateTitles,
+ images
+ } = props;
+
+ const index = title.toLowerCase().indexOf(query.toLowerCase());
+ const alternateTitle = index === -1 ?
+ getMatchingAlternateTile(alternateTitles, query) :
+ null;
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {
+ !!alternateTitle &&
+
+ {alternateTitle.title}
+
+ }
+
+
+ );
+}
+
+SeriesSearchResult.propTypes = {
+ query: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ images: 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..8850bed00
--- /dev/null
+++ b/frontend/src/Components/Page/PageConnector.js
@@ -0,0 +1,179 @@
+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 { fetchArtist } 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.tags,
+ (state) => state.settings,
+ (state) => state.app,
+ createDimensionsSelector(),
+ (series, tags, settings, app, dimensions) => {
+ const isPopulated = series.isPopulated &&
+ tags.isPopulated &&
+ settings.qualityProfiles.isPopulated &&
+ settings.ui.isPopulated;
+
+ const hasError = !!series.error ||
+ !!tags.error ||
+ !!settings.qualityProfiles.error ||
+ !!settings.ui.error;
+
+ return {
+ isPopulated,
+ hasError,
+ seriesError: series.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(fetchArtist());
+ },
+ 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.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,
+ 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..a416d268c
--- /dev/null
+++ b/frontend/src/Components/Page/PageContent.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+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..478633cb2
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBody.js
@@ -0,0 +1,51 @@
+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
+};
+
+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/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..225ca3022
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBar.css
@@ -0,0 +1,22 @@
+.jumpBar {
+ display: flex;
+ align-items: stretch;
+ align-content: 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..25844ec94
--- /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 Measure from 'react-measure';
+import dimensions from 'Styles/Variables/dimensions';
+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..809eadf5f
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js
@@ -0,0 +1,69 @@
+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..293d4ae7f
--- /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: 1;
+ height: 100vh;
+ }
+
+ .sidebar {
+ position: fixed;
+ z-index: 1;
+ 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..91d95a493
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js
@@ -0,0 +1,501 @@
+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: 'Artist',
+ to: '/',
+ alias: '/series',
+ children: [
+ {
+ title: 'Add New',
+ to: '/add/new'
+ },
+ {
+ title: 'Import',
+ to: '/add/import'
+ },
+ {
+ title: 'Mass Editor',
+ to: '/serieseditor'
+ },
+ {
+ title: 'Album Studio',
+ 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/ui',
+ children: [
+ {
+ title: 'UI',
+ to: '/settings/ui'
+ },
+ {
+ 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: 'General',
+ to: '/settings/general'
+ }
+ ]
+ },
+
+ {
+ 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('touchend', this.onTouchEnd);
+ window.addEventListener('touchcancel', this.onTouchCancel);
+ window.addEventListener('touchmove', this.onTouchMove);
+ }
+ }
+
+ 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('touchend', this.onTouchEnd);
+ window.removeEventListener('touchcancel', this.onTouchCancel);
+ window.removeEventListener('touchmove', this.onTouchMove);
+ }
+ }
+
+ //
+ // 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 touchStart = touches[0].pageX;
+ const isSidebarVisible = this.props.isSidebarVisible;
+
+ if (touches.length !== 1) {
+ return;
+ }
+
+ if (isSidebarVisible && (touchStart > 210 || touchStart < 50)) {
+ return;
+ } else if (!isSidebarVisible && touchStart > 50) {
+ return;
+ }
+
+ this._touchStartX = touchStart;
+ }
+
+ 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;
+ }
+
+ onTouchCancel = (event) => {
+ this._touchStartX = null;
+ }
+
+ onTouchMove = (event) => {
+ const touches = event.touches;
+ const currentTouchX = touches[0].pageX;
+ const currentTouchY = touches[0].pageY;
+
+ if (!this._touchStartX) {
+ return;
+ }
+
+ if (Math.abs(this._touchStartY - currentTouchY) > 20) {
+ return;
+ }
+
+ const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0);
+
+ this.setState({
+ transition: 'none',
+ transform
+ });
+ }
+
+ 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..450161705
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css
@@ -0,0 +1,48 @@
+.item {
+ border-left: 3px solid transparent;
+ color: $sidebarColor;
+ transition: border-left 0.3s ease-in-out;
+}
+
+.isActiveItem {
+ border-left: 3px solid $themeBlue;
+}
+
+.link {
+ display: block;
+ padding: 12px 24px;
+ color: $sidebarColor;
+
+ &:hover,
+ &:focus {
+ color: $themeBlue;
+ text-decoration: none;
+ }
+}
+
+.childLink {
+ composes: link;
+
+ padding: 10px 24px;
+}
+
+.isActiveLink {
+ color: $themeBlue;
+}
+
+.isActiveParentLink {
+ background-color: $sidebarActiveBackgroundColor;
+}
+
+.iconContainer {
+ display: inline-block;
+ width: 25px;
+}
+
+.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..23c6ccaf3
--- /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.string,
+ 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..e788303a2
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
@@ -0,0 +1,28 @@
+.toolbarButton {
+ composes: link from 'Components/Link/Link.css';
+
+ width: $toolbarButtonWidth;
+ text-align: center;
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+}
+
+.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..752ca15ac
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js
@@ -0,0 +1,56 @@
+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.string.isRequired,
+ spinningName: PropTypes.string,
+ 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..2767c163c
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css
@@ -0,0 +1,26 @@
+.sectionContainer {
+ display: flex;
+ flex: 1 1 10%;
+}
+
+.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..16ff98b72
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js
@@ -0,0 +1,220 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Measure from 'react-measure';
+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 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..43cea8139
--- /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..23dcfc70d
--- /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..a55eca90b
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.css
@@ -0,0 +1,14 @@
+.scroller {
+}
+
+.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..9cc9edec0
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.js
@@ -0,0 +1,115 @@
+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;
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ scrollTop
+ } = this.props;
+
+ if (scrollTop != null && scrollTop !== prevProps.scrollTop) {
+ this._scroller.scrollTop(scrollTop);
+ }
+ }
+
+ //
+ // Control
+
+ _setScrollRef = (ref) => {
+ this._scroller = ref;
+ }
+
+ _renderThumb = (props) => {
+ return (
+
+ );
+ }
+
+ _renderView = (props) => {
+ return (
+
+ );
+ }
+
+ //
+ // Listers
+
+ onScroll = (event) => {
+ const {
+ scrollTop,
+ scrollLeft
+ } = event.currentTarget;
+
+ 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..61005a527
--- /dev/null
+++ b/frontend/src/Components/Scroller/Scroller.css
@@ -0,0 +1,28 @@
+.scroller {
+ composes: scrollbar from 'Styles/Mixins/scroller.css';
+ composes: scrollbarTrack from 'Styles/Mixins/scroller.css';
+ composes: scrollbarThumb from 'Styles/Mixins/scroller.css';
+}
+
+.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..06cece1da
--- /dev/null
+++ b/frontend/src/Components/SignalRConnector.js
@@ -0,0 +1,326 @@
+import $ from 'jquery';
+import PropTypes from 'prop-types';
+import { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { 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';
+require('signalR');
+
+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 createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.isReconnecting,
+ (state) => state.queue.paged.isPopulated,
+ (isReconnecting, isQueuePopulated) => {
+ return {
+ isReconnecting,
+ isQueuePopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateCommand,
+ finishCommand,
+ setAppValue,
+ setVersion,
+ update,
+ updateItem,
+ removeItem,
+ fetchHealth,
+ fetchQueue,
+ fetchQueueDetails
+};
+
+class SignalRConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.signalRconnectionOptions = { transport: ['longPolling'] };
+ this.signalRconnection = null;
+ this.retryInterval = 5;
+ this.retryTimeoutId = 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() {
+ this.signalRconnection.stop();
+ this.signalRconnection = null;
+ }
+
+ //
+ // Control
+
+ retryConnection = () => {
+ this.retryTimeoutId = setTimeout(() => {
+ this.signalRconnection.start(this.signalRconnectionOptions);
+ this.retryInterval = Math.min(this.retryInterval + 5, 30);
+ }, this.retryInterval * 1000);
+ }
+
+ handleMessage = (message) => {
+ const {
+ name,
+ body
+ } = message;
+
+ if (name === 'calendar') {
+ this.handleCalendar(body);
+ return;
+ }
+
+ if (name === 'command') {
+ this.handleCommand(body);
+ return;
+ }
+
+ if (name === 'episode') {
+ this.handleEpisode(body);
+ return;
+ }
+
+ if (name === 'episodefile') {
+ this.handleEpisodeFile(body);
+ return;
+ }
+
+ if (name === 'health') {
+ this.handleHealth(body);
+ return;
+ }
+
+ if (name === 'series') {
+ this.handleSeries(body);
+ return;
+ }
+
+ if (name === 'queue') {
+ this.handleQueue(body);
+ return;
+ }
+
+ if (name === 'queue/details') {
+ this.handleQueueDetails(body);
+ return;
+ }
+
+ if (name === 'queue/status') {
+ this.handleQueueStatus(body);
+ return;
+ }
+
+ if (name === 'version') {
+ this.handleVersion(body);
+ return;
+ }
+
+ if (name === 'wanted/cutoff') {
+ this.handleWantedCutoff(body);
+ return;
+ }
+
+ if (name === 'wanted/missing') {
+ this.handleWantedMissing(body);
+ return;
+ }
+ }
+
+ handleCalendar = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'calendar',
+ updateOnly: true,
+ ...body.resource });
+ }
+ }
+
+ handleCommand = (body) => {
+ const resource = body.resource;
+ const state = resource.state;
+
+ if (state === 'completed') {
+ this.props.finishCommand(resource);
+ } else {
+ this.props.updateCommand(resource);
+ }
+ }
+
+ handleEpisode = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'episodes',
+ updateOnly: true,
+ ...body.resource });
+ }
+ }
+
+ handleEpisodeFile = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'episodeFiles',
+ ...body.resource });
+ }
+ }
+
+ handleHealth = (body) => {
+ this.props.fetchHealth();
+ }
+
+ handleSeries = (body) => {
+ const action = body.action;
+ const section = 'series';
+
+ if (action === 'updated') {
+ this.props.updateItem({ section, ...body.resource });
+ } else if (action === 'deleted') {
+ this.props.removeItem({ section, id: body.resource.id });
+ }
+ }
+
+ handleQueue = (body) => {
+ if (this.props.isQueuePopulated) {
+ this.props.fetchQueue();
+ }
+ }
+
+ handleQueueDetails = (body) => {
+ this.props.fetchQueueDetails();
+ }
+
+ handleQueueStatus = (body) => {
+ this.props.update({ section: 'queueStatus', data: body.resource });
+ }
+
+ handleVersion = (body) => {
+ const version = body.version;
+
+ this.props.setVersion({ version });
+ }
+
+ handleWantedCutoff = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'cutoffUnmet',
+ updateOnly: true,
+ ...body.resource });
+ }
+ }
+
+ handleWantedMissing = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'missing',
+ updateOnly: true,
+ ...body.resource });
+ }
+ }
+
+ //
+ // Listeners
+
+ onStateChanged = (change) => {
+ const state = getState(change.newState);
+ console.log(`SignalR: [${state}]`);
+
+ if (state === 'connected') {
+ this.props.setAppValue({
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: 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;
+ }
+
+ this.props.setAppValue({
+ isReconnecting: true
+ });
+ }
+
+ onDisconnected = () => {
+ if (this.props.isReconnecting) {
+ this.props.setAppValue({
+ isConnected: false,
+ isReconnecting: true,
+ isDisconnected: true
+ });
+
+ this.retryConnection();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return null;
+ }
+}
+
+SignalRConnector.propTypes = {
+ isReconnecting: PropTypes.bool.isRequired,
+ isQueuePopulated: PropTypes.bool.isRequired,
+ updateCommand: PropTypes.func.isRequired,
+ finishCommand: PropTypes.func.isRequired,
+ setAppValue: PropTypes.func.isRequired,
+ setVersion: PropTypes.func.isRequired,
+ update: PropTypes.func.isRequired,
+ updateItem: PropTypes.func.isRequired,
+ removeItem: PropTypes.func.isRequired,
+ fetchHealth: PropTypes.func.isRequired,
+ fetchQueue: PropTypes.func.isRequired,
+ fetchQueueDetails: 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..4c5cbb700
--- /dev/null
+++ b/frontend/src/Components/SpinnerIcon.js
@@ -0,0 +1,32 @@
+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.string.isRequired,
+ spinningName: PropTypes.string.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..874ae4aca
--- /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..b82cf9168
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.js
@@ -0,0 +1,71 @@
+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 });
+ }
+
+ //
+ // 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..90edf0285
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css
@@ -0,0 +1,14 @@
+.cell {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+ composes: truncate from 'Styles/Mixins/truncate.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..f66eec49a
--- /dev/null
+++ b/frontend/src/Components/Table/Table.js
@@ -0,0 +1,157 @@
+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,
+ 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,
+ 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..f1417d5e9
--- /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 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,
+ 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}
+
+ );
+ }
+}
+
+VirtualTableHeaderCell.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
+};
+
+VirtualTableHeaderCell.defaultProps = {
+ className: styles.headerCell,
+ isSortable: false
+};
+
+export default VirtualTableHeaderCell;
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..6a8e345f8
--- /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..03169f00c
--- /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 formLabelWidth = parseInt(dimensions.formLabelWidth);
+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 - formLabelWidth - 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..53d695e31
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
@@ -0,0 +1,242 @@
+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,
+ 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,
+ 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..e3fb645bd
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.css
@@ -0,0 +1,70 @@
+.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;
+}
+
+@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..e3bc10be7
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.js
@@ -0,0 +1,173 @@
+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));
+ }
+
+ //
+ // 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..9664733b4
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.css
@@ -0,0 +1,7 @@
+.row {
+ transition: background-color 500ms;
+
+ &:hover {
+ background-color: #fafbfc;
+ }
+}
diff --git a/frontend/src/Components/Table/TableRow.js b/frontend/src/Components/Table/TableRow.js
new file mode 100644
index 000000000..06bbbaee9
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './TableRow.css';
+
+function TableRow(props) {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+TableRow.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node
+};
+
+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..ecacfd82d
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import { WindowScroller } from 'react-virtualized';
+import { scrollDirections } from 'Helpers/Props';
+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;
+ this._table = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ //
+ // Control
+
+ rowGetter = ({ index }) => {
+ return this.props.items[index];
+ }
+
+ setTableRef = (ref) => {
+ this._table = ref;
+ }
+
+ forceUpdateGrid = () => {
+ this._table.recomputeGridSize();
+ }
+
+ scrollToRow = (rowIndex) => {
+ const scrollTop = (rowIndex + 1) * ROW_HEIGHT + 20;
+
+ // this._table.scrollToCell({ columnIndex: 0, rowIndex });
+ this.props.onScroll({ scrollTop });
+ }
+
+ //
+ // 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,
+ 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..c73508895
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableBody.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Grid } from 'react-virtualized';
+import styles from './VirtualTableBody.css';
+
+class VirtualTableBody extends Grid {
+
+ //
+ // Render
+
+ render() {
+ const {
+ autoContainerWidth,
+ containerStyle
+ } = this.props;
+
+ const { isScrolling } = this.state;
+
+ const totalColumnsWidth = this._columnSizeAndPositionManager.getTotalSize();
+ const totalRowsHeight = this._rowSizeAndPositionManager.getTotalSize();
+ const childrenToDisplay = this._childrenToDisplay;
+
+ if (childrenToDisplay.length > 0) {
+ return (
+
+
+ {childrenToDisplay}
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+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..515eacc71
--- /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..cb742eca0
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.css
@@ -0,0 +1,104 @@
+.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 {
+ padding: 20px;
+}
diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js
new file mode 100644
index 000000000..0cb2520e5
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.js
@@ -0,0 +1,136 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+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
+ };
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onMouseEnter = () => {
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this.setState({ isOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ anchor,
+ title,
+ body,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+
+
+
+
+ {title}
+
+
+
+ {body}
+
+
+
+ }
+
+ );
+ }
+}
+
+Popover.propTypes = {
+ 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..5b32c5783
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.js
@@ -0,0 +1,151 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+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._closeTimeout = null;
+
+ this.state = {
+ isOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onMouseEnter = () => {
+ if (this._closeTimeout) {
+ clearTimeout(this._closeTimeout);
+ }
+
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this._closeTimeout = setTimeout(() => {
+ this.setState({ isOpen: false });
+ }, 100);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ anchor,
+ tooltip,
+ kind,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+ }
+
+ );
+ }
+}
+
+Tooltip.propTypes = {
+ 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..dc0f431cd
--- /dev/null
+++ b/frontend/src/Components/keyboardShortcuts.js
@@ -0,0 +1,100 @@
+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) => {
+ if (this._mousetrapBindings[combo].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/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/FontAwesome.otf b/frontend/src/Content/Fonts/FontAwesome.otf
new file mode 100644
index 000000000..401ec0f36
Binary files /dev/null and b/frontend/src/Content/Fonts/FontAwesome.otf differ
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/src/UI/Content/fonts/ubuntumono-regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot
similarity index 100%
rename from src/UI/Content/fonts/ubuntumono-regular.eot
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.eot
diff --git a/src/UI/Content/fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
similarity index 100%
rename from src/UI/Content/fonts/UbuntuMono-Regular.ttf
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
diff --git a/src/UI/Content/fonts/ubuntumono-regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff
similarity index 100%
rename from src/UI/Content/fonts/ubuntumono-regular.woff
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.woff
diff --git a/frontend/src/Content/Fonts/font-awesome.css b/frontend/src/Content/Fonts/font-awesome.css
new file mode 100644
index 000000000..eab1cbb5b
--- /dev/null
+++ b/frontend/src/Content/Fonts/font-awesome.css
@@ -0,0 +1,2337 @@
+/*!
+ * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
+ */
+/* FONT PATH
+ * -------------------------- */
+@font-face {
+ font-family: 'FontAwesome';
+ src: url('fontawesome-webfont.eot?v=4.7.0');
+ src: url('fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('fontawesome-webfont.woff?v=4.7.0') format('woff'), url('fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');
+ font-weight: normal;
+ font-style: normal;
+}
+.fa {
+ display: inline-block;
+ font: normal normal normal 14px/1 FontAwesome;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+/* makes the font 33% larger relative to the icon container */
+.fa-lg {
+ font-size: 1.33333333em;
+ line-height: 0.75em;
+ vertical-align: -15%;
+}
+.fa-2x {
+ font-size: 2em;
+}
+.fa-3x {
+ font-size: 3em;
+}
+.fa-4x {
+ font-size: 4em;
+}
+.fa-5x {
+ font-size: 5em;
+}
+.fa-fw {
+ width: 1.28571429em;
+ text-align: center;
+}
+.fa-ul {
+ padding-left: 0;
+ margin-left: 2.14285714em;
+ list-style-type: none;
+}
+.fa-ul > li {
+ position: relative;
+}
+.fa-li {
+ position: absolute;
+ left: -2.14285714em;
+ width: 2.14285714em;
+ top: 0.14285714em;
+ text-align: center;
+}
+.fa-li.fa-lg {
+ left: -1.85714286em;
+}
+.fa-border {
+ padding: .2em .25em .15em;
+ border: solid 0.08em #eeeeee;
+ border-radius: .1em;
+}
+.fa-pull-left {
+ float: left;
+}
+.fa-pull-right {
+ float: right;
+}
+.fa.fa-pull-left {
+ margin-right: .3em;
+}
+.fa.fa-pull-right {
+ margin-left: .3em;
+}
+/* Deprecated as of 4.4.0 */
+.pull-right {
+ float: right;
+}
+.pull-left {
+ float: left;
+}
+.fa.pull-left {
+ margin-right: .3em;
+}
+.fa.pull-right {
+ margin-left: .3em;
+}
+.fa-spin {
+ -webkit-animation: fa-spin 2s infinite linear;
+ animation: fa-spin 2s infinite linear;
+}
+.fa-pulse {
+ -webkit-animation: fa-spin 1s infinite steps(8);
+ animation: fa-spin 1s infinite steps(8);
+}
+@-webkit-keyframes fa-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+@keyframes fa-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+.fa-rotate-90 {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";
+ -webkit-transform: rotate(90deg);
+ -ms-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+.fa-rotate-180 {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";
+ -webkit-transform: rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: rotate(180deg);
+}
+.fa-rotate-270 {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";
+ -webkit-transform: rotate(270deg);
+ -ms-transform: rotate(270deg);
+ transform: rotate(270deg);
+}
+.fa-flip-horizontal {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";
+ -webkit-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+}
+.fa-flip-vertical {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";
+ -webkit-transform: scale(1, -1);
+ -ms-transform: scale(1, -1);
+ transform: scale(1, -1);
+}
+:root .fa-rotate-90,
+:root .fa-rotate-180,
+:root .fa-rotate-270,
+:root .fa-flip-horizontal,
+:root .fa-flip-vertical {
+ filter: none;
+}
+.fa-stack {
+ position: relative;
+ display: inline-block;
+ width: 2em;
+ height: 2em;
+ line-height: 2em;
+ vertical-align: middle;
+}
+.fa-stack-1x,
+.fa-stack-2x {
+ position: absolute;
+ left: 0;
+ width: 100%;
+ text-align: center;
+}
+.fa-stack-1x {
+ line-height: inherit;
+}
+.fa-stack-2x {
+ font-size: 2em;
+}
+.fa-inverse {
+ color: #ffffff;
+}
+/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
+ readers do not read off random characters that represent icons */
+.fa-glass:before {
+ content: "\f000";
+}
+.fa-music:before {
+ content: "\f001";
+}
+.fa-search:before {
+ content: "\f002";
+}
+.fa-envelope-o:before {
+ content: "\f003";
+}
+.fa-heart:before {
+ content: "\f004";
+}
+.fa-star:before {
+ content: "\f005";
+}
+.fa-star-o:before {
+ content: "\f006";
+}
+.fa-user:before {
+ content: "\f007";
+}
+.fa-film:before {
+ content: "\f008";
+}
+.fa-th-large:before {
+ content: "\f009";
+}
+.fa-th:before {
+ content: "\f00a";
+}
+.fa-th-list:before {
+ content: "\f00b";
+}
+.fa-check:before {
+ content: "\f00c";
+}
+.fa-remove:before,
+.fa-close:before,
+.fa-times:before {
+ content: "\f00d";
+}
+.fa-search-plus:before {
+ content: "\f00e";
+}
+.fa-search-minus:before {
+ content: "\f010";
+}
+.fa-power-off:before {
+ content: "\f011";
+}
+.fa-signal:before {
+ content: "\f012";
+}
+.fa-gear:before,
+.fa-cog:before {
+ content: "\f013";
+}
+.fa-trash-o:before {
+ content: "\f014";
+}
+.fa-home:before {
+ content: "\f015";
+}
+.fa-file-o:before {
+ content: "\f016";
+}
+.fa-clock-o:before {
+ content: "\f017";
+}
+.fa-road:before {
+ content: "\f018";
+}
+.fa-download:before {
+ content: "\f019";
+}
+.fa-arrow-circle-o-down:before {
+ content: "\f01a";
+}
+.fa-arrow-circle-o-up:before {
+ content: "\f01b";
+}
+.fa-inbox:before {
+ content: "\f01c";
+}
+.fa-play-circle-o:before {
+ content: "\f01d";
+}
+.fa-rotate-right:before,
+.fa-repeat:before {
+ content: "\f01e";
+}
+.fa-refresh:before {
+ content: "\f021";
+}
+.fa-list-alt:before {
+ content: "\f022";
+}
+.fa-lock:before {
+ content: "\f023";
+}
+.fa-flag:before {
+ content: "\f024";
+}
+.fa-headphones:before {
+ content: "\f025";
+}
+.fa-volume-off:before {
+ content: "\f026";
+}
+.fa-volume-down:before {
+ content: "\f027";
+}
+.fa-volume-up:before {
+ content: "\f028";
+}
+.fa-qrcode:before {
+ content: "\f029";
+}
+.fa-barcode:before {
+ content: "\f02a";
+}
+.fa-tag:before {
+ content: "\f02b";
+}
+.fa-tags:before {
+ content: "\f02c";
+}
+.fa-book:before {
+ content: "\f02d";
+}
+.fa-bookmark:before {
+ content: "\f02e";
+}
+.fa-print:before {
+ content: "\f02f";
+}
+.fa-camera:before {
+ content: "\f030";
+}
+.fa-font:before {
+ content: "\f031";
+}
+.fa-bold:before {
+ content: "\f032";
+}
+.fa-italic:before {
+ content: "\f033";
+}
+.fa-text-height:before {
+ content: "\f034";
+}
+.fa-text-width:before {
+ content: "\f035";
+}
+.fa-align-left:before {
+ content: "\f036";
+}
+.fa-align-center:before {
+ content: "\f037";
+}
+.fa-align-right:before {
+ content: "\f038";
+}
+.fa-align-justify:before {
+ content: "\f039";
+}
+.fa-list:before {
+ content: "\f03a";
+}
+.fa-dedent:before,
+.fa-outdent:before {
+ content: "\f03b";
+}
+.fa-indent:before {
+ content: "\f03c";
+}
+.fa-video-camera:before {
+ content: "\f03d";
+}
+.fa-photo:before,
+.fa-image:before,
+.fa-picture-o:before {
+ content: "\f03e";
+}
+.fa-pencil:before {
+ content: "\f040";
+}
+.fa-map-marker:before {
+ content: "\f041";
+}
+.fa-adjust:before {
+ content: "\f042";
+}
+.fa-tint:before {
+ content: "\f043";
+}
+.fa-edit:before,
+.fa-pencil-square-o:before {
+ content: "\f044";
+}
+.fa-share-square-o:before {
+ content: "\f045";
+}
+.fa-check-square-o:before {
+ content: "\f046";
+}
+.fa-arrows:before {
+ content: "\f047";
+}
+.fa-step-backward:before {
+ content: "\f048";
+}
+.fa-fast-backward:before {
+ content: "\f049";
+}
+.fa-backward:before {
+ content: "\f04a";
+}
+.fa-play:before {
+ content: "\f04b";
+}
+.fa-pause:before {
+ content: "\f04c";
+}
+.fa-stop:before {
+ content: "\f04d";
+}
+.fa-forward:before {
+ content: "\f04e";
+}
+.fa-fast-forward:before {
+ content: "\f050";
+}
+.fa-step-forward:before {
+ content: "\f051";
+}
+.fa-eject:before {
+ content: "\f052";
+}
+.fa-chevron-left:before {
+ content: "\f053";
+}
+.fa-chevron-right:before {
+ content: "\f054";
+}
+.fa-plus-circle:before {
+ content: "\f055";
+}
+.fa-minus-circle:before {
+ content: "\f056";
+}
+.fa-times-circle:before {
+ content: "\f057";
+}
+.fa-check-circle:before {
+ content: "\f058";
+}
+.fa-question-circle:before {
+ content: "\f059";
+}
+.fa-info-circle:before {
+ content: "\f05a";
+}
+.fa-crosshairs:before {
+ content: "\f05b";
+}
+.fa-times-circle-o:before {
+ content: "\f05c";
+}
+.fa-check-circle-o:before {
+ content: "\f05d";
+}
+.fa-ban:before {
+ content: "\f05e";
+}
+.fa-arrow-left:before {
+ content: "\f060";
+}
+.fa-arrow-right:before {
+ content: "\f061";
+}
+.fa-arrow-up:before {
+ content: "\f062";
+}
+.fa-arrow-down:before {
+ content: "\f063";
+}
+.fa-mail-forward:before,
+.fa-share:before {
+ content: "\f064";
+}
+.fa-expand:before {
+ content: "\f065";
+}
+.fa-compress:before {
+ content: "\f066";
+}
+.fa-plus:before {
+ content: "\f067";
+}
+.fa-minus:before {
+ content: "\f068";
+}
+.fa-asterisk:before {
+ content: "\f069";
+}
+.fa-exclamation-circle:before {
+ content: "\f06a";
+}
+.fa-gift:before {
+ content: "\f06b";
+}
+.fa-leaf:before {
+ content: "\f06c";
+}
+.fa-fire:before {
+ content: "\f06d";
+}
+.fa-eye:before {
+ content: "\f06e";
+}
+.fa-eye-slash:before {
+ content: "\f070";
+}
+.fa-warning:before,
+.fa-exclamation-triangle:before {
+ content: "\f071";
+}
+.fa-plane:before {
+ content: "\f072";
+}
+.fa-calendar:before {
+ content: "\f073";
+}
+.fa-random:before {
+ content: "\f074";
+}
+.fa-comment:before {
+ content: "\f075";
+}
+.fa-magnet:before {
+ content: "\f076";
+}
+.fa-chevron-up:before {
+ content: "\f077";
+}
+.fa-chevron-down:before {
+ content: "\f078";
+}
+.fa-retweet:before {
+ content: "\f079";
+}
+.fa-shopping-cart:before {
+ content: "\f07a";
+}
+.fa-folder:before {
+ content: "\f07b";
+}
+.fa-folder-open:before {
+ content: "\f07c";
+}
+.fa-arrows-v:before {
+ content: "\f07d";
+}
+.fa-arrows-h:before {
+ content: "\f07e";
+}
+.fa-bar-chart-o:before,
+.fa-bar-chart:before {
+ content: "\f080";
+}
+.fa-twitter-square:before {
+ content: "\f081";
+}
+.fa-facebook-square:before {
+ content: "\f082";
+}
+.fa-camera-retro:before {
+ content: "\f083";
+}
+.fa-key:before {
+ content: "\f084";
+}
+.fa-gears:before,
+.fa-cogs:before {
+ content: "\f085";
+}
+.fa-comments:before {
+ content: "\f086";
+}
+.fa-thumbs-o-up:before {
+ content: "\f087";
+}
+.fa-thumbs-o-down:before {
+ content: "\f088";
+}
+.fa-star-half:before {
+ content: "\f089";
+}
+.fa-heart-o:before {
+ content: "\f08a";
+}
+.fa-sign-out:before {
+ content: "\f08b";
+}
+.fa-linkedin-square:before {
+ content: "\f08c";
+}
+.fa-thumb-tack:before {
+ content: "\f08d";
+}
+.fa-external-link:before {
+ content: "\f08e";
+}
+.fa-sign-in:before {
+ content: "\f090";
+}
+.fa-trophy:before {
+ content: "\f091";
+}
+.fa-github-square:before {
+ content: "\f092";
+}
+.fa-upload:before {
+ content: "\f093";
+}
+.fa-lemon-o:before {
+ content: "\f094";
+}
+.fa-phone:before {
+ content: "\f095";
+}
+.fa-square-o:before {
+ content: "\f096";
+}
+.fa-bookmark-o:before {
+ content: "\f097";
+}
+.fa-phone-square:before {
+ content: "\f098";
+}
+.fa-twitter:before {
+ content: "\f099";
+}
+.fa-facebook-f:before,
+.fa-facebook:before {
+ content: "\f09a";
+}
+.fa-github:before {
+ content: "\f09b";
+}
+.fa-unlock:before {
+ content: "\f09c";
+}
+.fa-credit-card:before {
+ content: "\f09d";
+}
+.fa-feed:before,
+.fa-rss:before {
+ content: "\f09e";
+}
+.fa-hdd-o:before {
+ content: "\f0a0";
+}
+.fa-bullhorn:before {
+ content: "\f0a1";
+}
+.fa-bell:before {
+ content: "\f0f3";
+}
+.fa-certificate:before {
+ content: "\f0a3";
+}
+.fa-hand-o-right:before {
+ content: "\f0a4";
+}
+.fa-hand-o-left:before {
+ content: "\f0a5";
+}
+.fa-hand-o-up:before {
+ content: "\f0a6";
+}
+.fa-hand-o-down:before {
+ content: "\f0a7";
+}
+.fa-arrow-circle-left:before {
+ content: "\f0a8";
+}
+.fa-arrow-circle-right:before {
+ content: "\f0a9";
+}
+.fa-arrow-circle-up:before {
+ content: "\f0aa";
+}
+.fa-arrow-circle-down:before {
+ content: "\f0ab";
+}
+.fa-globe:before {
+ content: "\f0ac";
+}
+.fa-wrench:before {
+ content: "\f0ad";
+}
+.fa-tasks:before {
+ content: "\f0ae";
+}
+.fa-filter:before {
+ content: "\f0b0";
+}
+.fa-briefcase:before {
+ content: "\f0b1";
+}
+.fa-arrows-alt:before {
+ content: "\f0b2";
+}
+.fa-group:before,
+.fa-users:before {
+ content: "\f0c0";
+}
+.fa-chain:before,
+.fa-link:before {
+ content: "\f0c1";
+}
+.fa-cloud:before {
+ content: "\f0c2";
+}
+.fa-flask:before {
+ content: "\f0c3";
+}
+.fa-cut:before,
+.fa-scissors:before {
+ content: "\f0c4";
+}
+.fa-copy:before,
+.fa-files-o:before {
+ content: "\f0c5";
+}
+.fa-paperclip:before {
+ content: "\f0c6";
+}
+.fa-save:before,
+.fa-floppy-o:before {
+ content: "\f0c7";
+}
+.fa-square:before {
+ content: "\f0c8";
+}
+.fa-navicon:before,
+.fa-reorder:before,
+.fa-bars:before {
+ content: "\f0c9";
+}
+.fa-list-ul:before {
+ content: "\f0ca";
+}
+.fa-list-ol:before {
+ content: "\f0cb";
+}
+.fa-strikethrough:before {
+ content: "\f0cc";
+}
+.fa-underline:before {
+ content: "\f0cd";
+}
+.fa-table:before {
+ content: "\f0ce";
+}
+.fa-magic:before {
+ content: "\f0d0";
+}
+.fa-truck:before {
+ content: "\f0d1";
+}
+.fa-pinterest:before {
+ content: "\f0d2";
+}
+.fa-pinterest-square:before {
+ content: "\f0d3";
+}
+.fa-google-plus-square:before {
+ content: "\f0d4";
+}
+.fa-google-plus:before {
+ content: "\f0d5";
+}
+.fa-money:before {
+ content: "\f0d6";
+}
+.fa-caret-down:before {
+ content: "\f0d7";
+}
+.fa-caret-up:before {
+ content: "\f0d8";
+}
+.fa-caret-left:before {
+ content: "\f0d9";
+}
+.fa-caret-right:before {
+ content: "\f0da";
+}
+.fa-columns:before {
+ content: "\f0db";
+}
+.fa-unsorted:before,
+.fa-sort:before {
+ content: "\f0dc";
+}
+.fa-sort-down:before,
+.fa-sort-desc:before {
+ content: "\f0dd";
+}
+.fa-sort-up:before,
+.fa-sort-asc:before {
+ content: "\f0de";
+}
+.fa-envelope:before {
+ content: "\f0e0";
+}
+.fa-linkedin:before {
+ content: "\f0e1";
+}
+.fa-rotate-left:before,
+.fa-undo:before {
+ content: "\f0e2";
+}
+.fa-legal:before,
+.fa-gavel:before {
+ content: "\f0e3";
+}
+.fa-dashboard:before,
+.fa-tachometer:before {
+ content: "\f0e4";
+}
+.fa-comment-o:before {
+ content: "\f0e5";
+}
+.fa-comments-o:before {
+ content: "\f0e6";
+}
+.fa-flash:before,
+.fa-bolt:before {
+ content: "\f0e7";
+}
+.fa-sitemap:before {
+ content: "\f0e8";
+}
+.fa-umbrella:before {
+ content: "\f0e9";
+}
+.fa-paste:before,
+.fa-clipboard:before {
+ content: "\f0ea";
+}
+.fa-lightbulb-o:before {
+ content: "\f0eb";
+}
+.fa-exchange:before {
+ content: "\f0ec";
+}
+.fa-cloud-download:before {
+ content: "\f0ed";
+}
+.fa-cloud-upload:before {
+ content: "\f0ee";
+}
+.fa-user-md:before {
+ content: "\f0f0";
+}
+.fa-stethoscope:before {
+ content: "\f0f1";
+}
+.fa-suitcase:before {
+ content: "\f0f2";
+}
+.fa-bell-o:before {
+ content: "\f0a2";
+}
+.fa-coffee:before {
+ content: "\f0f4";
+}
+.fa-cutlery:before {
+ content: "\f0f5";
+}
+.fa-file-text-o:before {
+ content: "\f0f6";
+}
+.fa-building-o:before {
+ content: "\f0f7";
+}
+.fa-hospital-o:before {
+ content: "\f0f8";
+}
+.fa-ambulance:before {
+ content: "\f0f9";
+}
+.fa-medkit:before {
+ content: "\f0fa";
+}
+.fa-fighter-jet:before {
+ content: "\f0fb";
+}
+.fa-beer:before {
+ content: "\f0fc";
+}
+.fa-h-square:before {
+ content: "\f0fd";
+}
+.fa-plus-square:before {
+ content: "\f0fe";
+}
+.fa-angle-double-left:before {
+ content: "\f100";
+}
+.fa-angle-double-right:before {
+ content: "\f101";
+}
+.fa-angle-double-up:before {
+ content: "\f102";
+}
+.fa-angle-double-down:before {
+ content: "\f103";
+}
+.fa-angle-left:before {
+ content: "\f104";
+}
+.fa-angle-right:before {
+ content: "\f105";
+}
+.fa-angle-up:before {
+ content: "\f106";
+}
+.fa-angle-down:before {
+ content: "\f107";
+}
+.fa-desktop:before {
+ content: "\f108";
+}
+.fa-laptop:before {
+ content: "\f109";
+}
+.fa-tablet:before {
+ content: "\f10a";
+}
+.fa-mobile-phone:before,
+.fa-mobile:before {
+ content: "\f10b";
+}
+.fa-circle-o:before {
+ content: "\f10c";
+}
+.fa-quote-left:before {
+ content: "\f10d";
+}
+.fa-quote-right:before {
+ content: "\f10e";
+}
+.fa-spinner:before {
+ content: "\f110";
+}
+.fa-circle:before {
+ content: "\f111";
+}
+.fa-mail-reply:before,
+.fa-reply:before {
+ content: "\f112";
+}
+.fa-github-alt:before {
+ content: "\f113";
+}
+.fa-folder-o:before {
+ content: "\f114";
+}
+.fa-folder-open-o:before {
+ content: "\f115";
+}
+.fa-smile-o:before {
+ content: "\f118";
+}
+.fa-frown-o:before {
+ content: "\f119";
+}
+.fa-meh-o:before {
+ content: "\f11a";
+}
+.fa-gamepad:before {
+ content: "\f11b";
+}
+.fa-keyboard-o:before {
+ content: "\f11c";
+}
+.fa-flag-o:before {
+ content: "\f11d";
+}
+.fa-flag-checkered:before {
+ content: "\f11e";
+}
+.fa-terminal:before {
+ content: "\f120";
+}
+.fa-code:before {
+ content: "\f121";
+}
+.fa-mail-reply-all:before,
+.fa-reply-all:before {
+ content: "\f122";
+}
+.fa-star-half-empty:before,
+.fa-star-half-full:before,
+.fa-star-half-o:before {
+ content: "\f123";
+}
+.fa-location-arrow:before {
+ content: "\f124";
+}
+.fa-crop:before {
+ content: "\f125";
+}
+.fa-code-fork:before {
+ content: "\f126";
+}
+.fa-unlink:before,
+.fa-chain-broken:before {
+ content: "\f127";
+}
+.fa-question:before {
+ content: "\f128";
+}
+.fa-info:before {
+ content: "\f129";
+}
+.fa-exclamation:before {
+ content: "\f12a";
+}
+.fa-superscript:before {
+ content: "\f12b";
+}
+.fa-subscript:before {
+ content: "\f12c";
+}
+.fa-eraser:before {
+ content: "\f12d";
+}
+.fa-puzzle-piece:before {
+ content: "\f12e";
+}
+.fa-microphone:before {
+ content: "\f130";
+}
+.fa-microphone-slash:before {
+ content: "\f131";
+}
+.fa-shield:before {
+ content: "\f132";
+}
+.fa-calendar-o:before {
+ content: "\f133";
+}
+.fa-fire-extinguisher:before {
+ content: "\f134";
+}
+.fa-rocket:before {
+ content: "\f135";
+}
+.fa-maxcdn:before {
+ content: "\f136";
+}
+.fa-chevron-circle-left:before {
+ content: "\f137";
+}
+.fa-chevron-circle-right:before {
+ content: "\f138";
+}
+.fa-chevron-circle-up:before {
+ content: "\f139";
+}
+.fa-chevron-circle-down:before {
+ content: "\f13a";
+}
+.fa-html5:before {
+ content: "\f13b";
+}
+.fa-css3:before {
+ content: "\f13c";
+}
+.fa-anchor:before {
+ content: "\f13d";
+}
+.fa-unlock-alt:before {
+ content: "\f13e";
+}
+.fa-bullseye:before {
+ content: "\f140";
+}
+.fa-ellipsis-h:before {
+ content: "\f141";
+}
+.fa-ellipsis-v:before {
+ content: "\f142";
+}
+.fa-rss-square:before {
+ content: "\f143";
+}
+.fa-play-circle:before {
+ content: "\f144";
+}
+.fa-ticket:before {
+ content: "\f145";
+}
+.fa-minus-square:before {
+ content: "\f146";
+}
+.fa-minus-square-o:before {
+ content: "\f147";
+}
+.fa-level-up:before {
+ content: "\f148";
+}
+.fa-level-down:before {
+ content: "\f149";
+}
+.fa-check-square:before {
+ content: "\f14a";
+}
+.fa-pencil-square:before {
+ content: "\f14b";
+}
+.fa-external-link-square:before {
+ content: "\f14c";
+}
+.fa-share-square:before {
+ content: "\f14d";
+}
+.fa-compass:before {
+ content: "\f14e";
+}
+.fa-toggle-down:before,
+.fa-caret-square-o-down:before {
+ content: "\f150";
+}
+.fa-toggle-up:before,
+.fa-caret-square-o-up:before {
+ content: "\f151";
+}
+.fa-toggle-right:before,
+.fa-caret-square-o-right:before {
+ content: "\f152";
+}
+.fa-euro:before,
+.fa-eur:before {
+ content: "\f153";
+}
+.fa-gbp:before {
+ content: "\f154";
+}
+.fa-dollar:before,
+.fa-usd:before {
+ content: "\f155";
+}
+.fa-rupee:before,
+.fa-inr:before {
+ content: "\f156";
+}
+.fa-cny:before,
+.fa-rmb:before,
+.fa-yen:before,
+.fa-jpy:before {
+ content: "\f157";
+}
+.fa-ruble:before,
+.fa-rouble:before,
+.fa-rub:before {
+ content: "\f158";
+}
+.fa-won:before,
+.fa-krw:before {
+ content: "\f159";
+}
+.fa-bitcoin:before,
+.fa-btc:before {
+ content: "\f15a";
+}
+.fa-file:before {
+ content: "\f15b";
+}
+.fa-file-text:before {
+ content: "\f15c";
+}
+.fa-sort-alpha-asc:before {
+ content: "\f15d";
+}
+.fa-sort-alpha-desc:before {
+ content: "\f15e";
+}
+.fa-sort-amount-asc:before {
+ content: "\f160";
+}
+.fa-sort-amount-desc:before {
+ content: "\f161";
+}
+.fa-sort-numeric-asc:before {
+ content: "\f162";
+}
+.fa-sort-numeric-desc:before {
+ content: "\f163";
+}
+.fa-thumbs-up:before {
+ content: "\f164";
+}
+.fa-thumbs-down:before {
+ content: "\f165";
+}
+.fa-youtube-square:before {
+ content: "\f166";
+}
+.fa-youtube:before {
+ content: "\f167";
+}
+.fa-xing:before {
+ content: "\f168";
+}
+.fa-xing-square:before {
+ content: "\f169";
+}
+.fa-youtube-play:before {
+ content: "\f16a";
+}
+.fa-dropbox:before {
+ content: "\f16b";
+}
+.fa-stack-overflow:before {
+ content: "\f16c";
+}
+.fa-instagram:before {
+ content: "\f16d";
+}
+.fa-flickr:before {
+ content: "\f16e";
+}
+.fa-adn:before {
+ content: "\f170";
+}
+.fa-bitbucket:before {
+ content: "\f171";
+}
+.fa-bitbucket-square:before {
+ content: "\f172";
+}
+.fa-tumblr:before {
+ content: "\f173";
+}
+.fa-tumblr-square:before {
+ content: "\f174";
+}
+.fa-long-arrow-down:before {
+ content: "\f175";
+}
+.fa-long-arrow-up:before {
+ content: "\f176";
+}
+.fa-long-arrow-left:before {
+ content: "\f177";
+}
+.fa-long-arrow-right:before {
+ content: "\f178";
+}
+.fa-apple:before {
+ content: "\f179";
+}
+.fa-windows:before {
+ content: "\f17a";
+}
+.fa-android:before {
+ content: "\f17b";
+}
+.fa-linux:before {
+ content: "\f17c";
+}
+.fa-dribbble:before {
+ content: "\f17d";
+}
+.fa-skype:before {
+ content: "\f17e";
+}
+.fa-foursquare:before {
+ content: "\f180";
+}
+.fa-trello:before {
+ content: "\f181";
+}
+.fa-female:before {
+ content: "\f182";
+}
+.fa-male:before {
+ content: "\f183";
+}
+.fa-gittip:before,
+.fa-gratipay:before {
+ content: "\f184";
+}
+.fa-sun-o:before {
+ content: "\f185";
+}
+.fa-moon-o:before {
+ content: "\f186";
+}
+.fa-archive:before {
+ content: "\f187";
+}
+.fa-bug:before {
+ content: "\f188";
+}
+.fa-vk:before {
+ content: "\f189";
+}
+.fa-weibo:before {
+ content: "\f18a";
+}
+.fa-renren:before {
+ content: "\f18b";
+}
+.fa-pagelines:before {
+ content: "\f18c";
+}
+.fa-stack-exchange:before {
+ content: "\f18d";
+}
+.fa-arrow-circle-o-right:before {
+ content: "\f18e";
+}
+.fa-arrow-circle-o-left:before {
+ content: "\f190";
+}
+.fa-toggle-left:before,
+.fa-caret-square-o-left:before {
+ content: "\f191";
+}
+.fa-dot-circle-o:before {
+ content: "\f192";
+}
+.fa-wheelchair:before {
+ content: "\f193";
+}
+.fa-vimeo-square:before {
+ content: "\f194";
+}
+.fa-turkish-lira:before,
+.fa-try:before {
+ content: "\f195";
+}
+.fa-plus-square-o:before {
+ content: "\f196";
+}
+.fa-space-shuttle:before {
+ content: "\f197";
+}
+.fa-slack:before {
+ content: "\f198";
+}
+.fa-envelope-square:before {
+ content: "\f199";
+}
+.fa-wordpress:before {
+ content: "\f19a";
+}
+.fa-openid:before {
+ content: "\f19b";
+}
+.fa-institution:before,
+.fa-bank:before,
+.fa-university:before {
+ content: "\f19c";
+}
+.fa-mortar-board:before,
+.fa-graduation-cap:before {
+ content: "\f19d";
+}
+.fa-yahoo:before {
+ content: "\f19e";
+}
+.fa-google:before {
+ content: "\f1a0";
+}
+.fa-reddit:before {
+ content: "\f1a1";
+}
+.fa-reddit-square:before {
+ content: "\f1a2";
+}
+.fa-stumbleupon-circle:before {
+ content: "\f1a3";
+}
+.fa-stumbleupon:before {
+ content: "\f1a4";
+}
+.fa-delicious:before {
+ content: "\f1a5";
+}
+.fa-digg:before {
+ content: "\f1a6";
+}
+.fa-pied-piper-pp:before {
+ content: "\f1a7";
+}
+.fa-pied-piper-alt:before {
+ content: "\f1a8";
+}
+.fa-drupal:before {
+ content: "\f1a9";
+}
+.fa-joomla:before {
+ content: "\f1aa";
+}
+.fa-language:before {
+ content: "\f1ab";
+}
+.fa-fax:before {
+ content: "\f1ac";
+}
+.fa-building:before {
+ content: "\f1ad";
+}
+.fa-child:before {
+ content: "\f1ae";
+}
+.fa-paw:before {
+ content: "\f1b0";
+}
+.fa-spoon:before {
+ content: "\f1b1";
+}
+.fa-cube:before {
+ content: "\f1b2";
+}
+.fa-cubes:before {
+ content: "\f1b3";
+}
+.fa-behance:before {
+ content: "\f1b4";
+}
+.fa-behance-square:before {
+ content: "\f1b5";
+}
+.fa-steam:before {
+ content: "\f1b6";
+}
+.fa-steam-square:before {
+ content: "\f1b7";
+}
+.fa-recycle:before {
+ content: "\f1b8";
+}
+.fa-automobile:before,
+.fa-car:before {
+ content: "\f1b9";
+}
+.fa-cab:before,
+.fa-taxi:before {
+ content: "\f1ba";
+}
+.fa-tree:before {
+ content: "\f1bb";
+}
+.fa-spotify:before {
+ content: "\f1bc";
+}
+.fa-deviantart:before {
+ content: "\f1bd";
+}
+.fa-soundcloud:before {
+ content: "\f1be";
+}
+.fa-database:before {
+ content: "\f1c0";
+}
+.fa-file-pdf-o:before {
+ content: "\f1c1";
+}
+.fa-file-word-o:before {
+ content: "\f1c2";
+}
+.fa-file-excel-o:before {
+ content: "\f1c3";
+}
+.fa-file-powerpoint-o:before {
+ content: "\f1c4";
+}
+.fa-file-photo-o:before,
+.fa-file-picture-o:before,
+.fa-file-image-o:before {
+ content: "\f1c5";
+}
+.fa-file-zip-o:before,
+.fa-file-archive-o:before {
+ content: "\f1c6";
+}
+.fa-file-sound-o:before,
+.fa-file-audio-o:before {
+ content: "\f1c7";
+}
+.fa-file-movie-o:before,
+.fa-file-video-o:before {
+ content: "\f1c8";
+}
+.fa-file-code-o:before {
+ content: "\f1c9";
+}
+.fa-vine:before {
+ content: "\f1ca";
+}
+.fa-codepen:before {
+ content: "\f1cb";
+}
+.fa-jsfiddle:before {
+ content: "\f1cc";
+}
+.fa-life-bouy:before,
+.fa-life-buoy:before,
+.fa-life-saver:before,
+.fa-support:before,
+.fa-life-ring:before {
+ content: "\f1cd";
+}
+.fa-circle-o-notch:before {
+ content: "\f1ce";
+}
+.fa-ra:before,
+.fa-resistance:before,
+.fa-rebel:before {
+ content: "\f1d0";
+}
+.fa-ge:before,
+.fa-empire:before {
+ content: "\f1d1";
+}
+.fa-git-square:before {
+ content: "\f1d2";
+}
+.fa-git:before {
+ content: "\f1d3";
+}
+.fa-y-combinator-square:before,
+.fa-yc-square:before,
+.fa-hacker-news:before {
+ content: "\f1d4";
+}
+.fa-tencent-weibo:before {
+ content: "\f1d5";
+}
+.fa-qq:before {
+ content: "\f1d6";
+}
+.fa-wechat:before,
+.fa-weixin:before {
+ content: "\f1d7";
+}
+.fa-send:before,
+.fa-paper-plane:before {
+ content: "\f1d8";
+}
+.fa-send-o:before,
+.fa-paper-plane-o:before {
+ content: "\f1d9";
+}
+.fa-history:before {
+ content: "\f1da";
+}
+.fa-circle-thin:before {
+ content: "\f1db";
+}
+.fa-header:before {
+ content: "\f1dc";
+}
+.fa-paragraph:before {
+ content: "\f1dd";
+}
+.fa-sliders:before {
+ content: "\f1de";
+}
+.fa-share-alt:before {
+ content: "\f1e0";
+}
+.fa-share-alt-square:before {
+ content: "\f1e1";
+}
+.fa-bomb:before {
+ content: "\f1e2";
+}
+.fa-soccer-ball-o:before,
+.fa-futbol-o:before {
+ content: "\f1e3";
+}
+.fa-tty:before {
+ content: "\f1e4";
+}
+.fa-binoculars:before {
+ content: "\f1e5";
+}
+.fa-plug:before {
+ content: "\f1e6";
+}
+.fa-slideshare:before {
+ content: "\f1e7";
+}
+.fa-twitch:before {
+ content: "\f1e8";
+}
+.fa-yelp:before {
+ content: "\f1e9";
+}
+.fa-newspaper-o:before {
+ content: "\f1ea";
+}
+.fa-wifi:before {
+ content: "\f1eb";
+}
+.fa-calculator:before {
+ content: "\f1ec";
+}
+.fa-paypal:before {
+ content: "\f1ed";
+}
+.fa-google-wallet:before {
+ content: "\f1ee";
+}
+.fa-cc-visa:before {
+ content: "\f1f0";
+}
+.fa-cc-mastercard:before {
+ content: "\f1f1";
+}
+.fa-cc-discover:before {
+ content: "\f1f2";
+}
+.fa-cc-amex:before {
+ content: "\f1f3";
+}
+.fa-cc-paypal:before {
+ content: "\f1f4";
+}
+.fa-cc-stripe:before {
+ content: "\f1f5";
+}
+.fa-bell-slash:before {
+ content: "\f1f6";
+}
+.fa-bell-slash-o:before {
+ content: "\f1f7";
+}
+.fa-trash:before {
+ content: "\f1f8";
+}
+.fa-copyright:before {
+ content: "\f1f9";
+}
+.fa-at:before {
+ content: "\f1fa";
+}
+.fa-eyedropper:before {
+ content: "\f1fb";
+}
+.fa-paint-brush:before {
+ content: "\f1fc";
+}
+.fa-birthday-cake:before {
+ content: "\f1fd";
+}
+.fa-area-chart:before {
+ content: "\f1fe";
+}
+.fa-pie-chart:before {
+ content: "\f200";
+}
+.fa-line-chart:before {
+ content: "\f201";
+}
+.fa-lastfm:before {
+ content: "\f202";
+}
+.fa-lastfm-square:before {
+ content: "\f203";
+}
+.fa-toggle-off:before {
+ content: "\f204";
+}
+.fa-toggle-on:before {
+ content: "\f205";
+}
+.fa-bicycle:before {
+ content: "\f206";
+}
+.fa-bus:before {
+ content: "\f207";
+}
+.fa-ioxhost:before {
+ content: "\f208";
+}
+.fa-angellist:before {
+ content: "\f209";
+}
+.fa-cc:before {
+ content: "\f20a";
+}
+.fa-shekel:before,
+.fa-sheqel:before,
+.fa-ils:before {
+ content: "\f20b";
+}
+.fa-meanpath:before {
+ content: "\f20c";
+}
+.fa-buysellads:before {
+ content: "\f20d";
+}
+.fa-connectdevelop:before {
+ content: "\f20e";
+}
+.fa-dashcube:before {
+ content: "\f210";
+}
+.fa-forumbee:before {
+ content: "\f211";
+}
+.fa-leanpub:before {
+ content: "\f212";
+}
+.fa-sellsy:before {
+ content: "\f213";
+}
+.fa-shirtsinbulk:before {
+ content: "\f214";
+}
+.fa-simplybuilt:before {
+ content: "\f215";
+}
+.fa-skyatlas:before {
+ content: "\f216";
+}
+.fa-cart-plus:before {
+ content: "\f217";
+}
+.fa-cart-arrow-down:before {
+ content: "\f218";
+}
+.fa-diamond:before {
+ content: "\f219";
+}
+.fa-ship:before {
+ content: "\f21a";
+}
+.fa-user-secret:before {
+ content: "\f21b";
+}
+.fa-motorcycle:before {
+ content: "\f21c";
+}
+.fa-street-view:before {
+ content: "\f21d";
+}
+.fa-heartbeat:before {
+ content: "\f21e";
+}
+.fa-venus:before {
+ content: "\f221";
+}
+.fa-mars:before {
+ content: "\f222";
+}
+.fa-mercury:before {
+ content: "\f223";
+}
+.fa-intersex:before,
+.fa-transgender:before {
+ content: "\f224";
+}
+.fa-transgender-alt:before {
+ content: "\f225";
+}
+.fa-venus-double:before {
+ content: "\f226";
+}
+.fa-mars-double:before {
+ content: "\f227";
+}
+.fa-venus-mars:before {
+ content: "\f228";
+}
+.fa-mars-stroke:before {
+ content: "\f229";
+}
+.fa-mars-stroke-v:before {
+ content: "\f22a";
+}
+.fa-mars-stroke-h:before {
+ content: "\f22b";
+}
+.fa-neuter:before {
+ content: "\f22c";
+}
+.fa-genderless:before {
+ content: "\f22d";
+}
+.fa-facebook-official:before {
+ content: "\f230";
+}
+.fa-pinterest-p:before {
+ content: "\f231";
+}
+.fa-whatsapp:before {
+ content: "\f232";
+}
+.fa-server:before {
+ content: "\f233";
+}
+.fa-user-plus:before {
+ content: "\f234";
+}
+.fa-user-times:before {
+ content: "\f235";
+}
+.fa-hotel:before,
+.fa-bed:before {
+ content: "\f236";
+}
+.fa-viacoin:before {
+ content: "\f237";
+}
+.fa-train:before {
+ content: "\f238";
+}
+.fa-subway:before {
+ content: "\f239";
+}
+.fa-medium:before {
+ content: "\f23a";
+}
+.fa-yc:before,
+.fa-y-combinator:before {
+ content: "\f23b";
+}
+.fa-optin-monster:before {
+ content: "\f23c";
+}
+.fa-opencart:before {
+ content: "\f23d";
+}
+.fa-expeditedssl:before {
+ content: "\f23e";
+}
+.fa-battery-4:before,
+.fa-battery:before,
+.fa-battery-full:before {
+ content: "\f240";
+}
+.fa-battery-3:before,
+.fa-battery-three-quarters:before {
+ content: "\f241";
+}
+.fa-battery-2:before,
+.fa-battery-half:before {
+ content: "\f242";
+}
+.fa-battery-1:before,
+.fa-battery-quarter:before {
+ content: "\f243";
+}
+.fa-battery-0:before,
+.fa-battery-empty:before {
+ content: "\f244";
+}
+.fa-mouse-pointer:before {
+ content: "\f245";
+}
+.fa-i-cursor:before {
+ content: "\f246";
+}
+.fa-object-group:before {
+ content: "\f247";
+}
+.fa-object-ungroup:before {
+ content: "\f248";
+}
+.fa-sticky-note:before {
+ content: "\f249";
+}
+.fa-sticky-note-o:before {
+ content: "\f24a";
+}
+.fa-cc-jcb:before {
+ content: "\f24b";
+}
+.fa-cc-diners-club:before {
+ content: "\f24c";
+}
+.fa-clone:before {
+ content: "\f24d";
+}
+.fa-balance-scale:before {
+ content: "\f24e";
+}
+.fa-hourglass-o:before {
+ content: "\f250";
+}
+.fa-hourglass-1:before,
+.fa-hourglass-start:before {
+ content: "\f251";
+}
+.fa-hourglass-2:before,
+.fa-hourglass-half:before {
+ content: "\f252";
+}
+.fa-hourglass-3:before,
+.fa-hourglass-end:before {
+ content: "\f253";
+}
+.fa-hourglass:before {
+ content: "\f254";
+}
+.fa-hand-grab-o:before,
+.fa-hand-rock-o:before {
+ content: "\f255";
+}
+.fa-hand-stop-o:before,
+.fa-hand-paper-o:before {
+ content: "\f256";
+}
+.fa-hand-scissors-o:before {
+ content: "\f257";
+}
+.fa-hand-lizard-o:before {
+ content: "\f258";
+}
+.fa-hand-spock-o:before {
+ content: "\f259";
+}
+.fa-hand-pointer-o:before {
+ content: "\f25a";
+}
+.fa-hand-peace-o:before {
+ content: "\f25b";
+}
+.fa-trademark:before {
+ content: "\f25c";
+}
+.fa-registered:before {
+ content: "\f25d";
+}
+.fa-creative-commons:before {
+ content: "\f25e";
+}
+.fa-gg:before {
+ content: "\f260";
+}
+.fa-gg-circle:before {
+ content: "\f261";
+}
+.fa-tripadvisor:before {
+ content: "\f262";
+}
+.fa-odnoklassniki:before {
+ content: "\f263";
+}
+.fa-odnoklassniki-square:before {
+ content: "\f264";
+}
+.fa-get-pocket:before {
+ content: "\f265";
+}
+.fa-wikipedia-w:before {
+ content: "\f266";
+}
+.fa-safari:before {
+ content: "\f267";
+}
+.fa-chrome:before {
+ content: "\f268";
+}
+.fa-firefox:before {
+ content: "\f269";
+}
+.fa-opera:before {
+ content: "\f26a";
+}
+.fa-internet-explorer:before {
+ content: "\f26b";
+}
+.fa-tv:before,
+.fa-television:before {
+ content: "\f26c";
+}
+.fa-contao:before {
+ content: "\f26d";
+}
+.fa-500px:before {
+ content: "\f26e";
+}
+.fa-amazon:before {
+ content: "\f270";
+}
+.fa-calendar-plus-o:before {
+ content: "\f271";
+}
+.fa-calendar-minus-o:before {
+ content: "\f272";
+}
+.fa-calendar-times-o:before {
+ content: "\f273";
+}
+.fa-calendar-check-o:before {
+ content: "\f274";
+}
+.fa-industry:before {
+ content: "\f275";
+}
+.fa-map-pin:before {
+ content: "\f276";
+}
+.fa-map-signs:before {
+ content: "\f277";
+}
+.fa-map-o:before {
+ content: "\f278";
+}
+.fa-map:before {
+ content: "\f279";
+}
+.fa-commenting:before {
+ content: "\f27a";
+}
+.fa-commenting-o:before {
+ content: "\f27b";
+}
+.fa-houzz:before {
+ content: "\f27c";
+}
+.fa-vimeo:before {
+ content: "\f27d";
+}
+.fa-black-tie:before {
+ content: "\f27e";
+}
+.fa-fonticons:before {
+ content: "\f280";
+}
+.fa-reddit-alien:before {
+ content: "\f281";
+}
+.fa-edge:before {
+ content: "\f282";
+}
+.fa-credit-card-alt:before {
+ content: "\f283";
+}
+.fa-codiepie:before {
+ content: "\f284";
+}
+.fa-modx:before {
+ content: "\f285";
+}
+.fa-fort-awesome:before {
+ content: "\f286";
+}
+.fa-usb:before {
+ content: "\f287";
+}
+.fa-product-hunt:before {
+ content: "\f288";
+}
+.fa-mixcloud:before {
+ content: "\f289";
+}
+.fa-scribd:before {
+ content: "\f28a";
+}
+.fa-pause-circle:before {
+ content: "\f28b";
+}
+.fa-pause-circle-o:before {
+ content: "\f28c";
+}
+.fa-stop-circle:before {
+ content: "\f28d";
+}
+.fa-stop-circle-o:before {
+ content: "\f28e";
+}
+.fa-shopping-bag:before {
+ content: "\f290";
+}
+.fa-shopping-basket:before {
+ content: "\f291";
+}
+.fa-hashtag:before {
+ content: "\f292";
+}
+.fa-bluetooth:before {
+ content: "\f293";
+}
+.fa-bluetooth-b:before {
+ content: "\f294";
+}
+.fa-percent:before {
+ content: "\f295";
+}
+.fa-gitlab:before {
+ content: "\f296";
+}
+.fa-wpbeginner:before {
+ content: "\f297";
+}
+.fa-wpforms:before {
+ content: "\f298";
+}
+.fa-envira:before {
+ content: "\f299";
+}
+.fa-universal-access:before {
+ content: "\f29a";
+}
+.fa-wheelchair-alt:before {
+ content: "\f29b";
+}
+.fa-question-circle-o:before {
+ content: "\f29c";
+}
+.fa-blind:before {
+ content: "\f29d";
+}
+.fa-audio-description:before {
+ content: "\f29e";
+}
+.fa-volume-control-phone:before {
+ content: "\f2a0";
+}
+.fa-braille:before {
+ content: "\f2a1";
+}
+.fa-assistive-listening-systems:before {
+ content: "\f2a2";
+}
+.fa-asl-interpreting:before,
+.fa-american-sign-language-interpreting:before {
+ content: "\f2a3";
+}
+.fa-deafness:before,
+.fa-hard-of-hearing:before,
+.fa-deaf:before {
+ content: "\f2a4";
+}
+.fa-glide:before {
+ content: "\f2a5";
+}
+.fa-glide-g:before {
+ content: "\f2a6";
+}
+.fa-signing:before,
+.fa-sign-language:before {
+ content: "\f2a7";
+}
+.fa-low-vision:before {
+ content: "\f2a8";
+}
+.fa-viadeo:before {
+ content: "\f2a9";
+}
+.fa-viadeo-square:before {
+ content: "\f2aa";
+}
+.fa-snapchat:before {
+ content: "\f2ab";
+}
+.fa-snapchat-ghost:before {
+ content: "\f2ac";
+}
+.fa-snapchat-square:before {
+ content: "\f2ad";
+}
+.fa-pied-piper:before {
+ content: "\f2ae";
+}
+.fa-first-order:before {
+ content: "\f2b0";
+}
+.fa-yoast:before {
+ content: "\f2b1";
+}
+.fa-themeisle:before {
+ content: "\f2b2";
+}
+.fa-google-plus-circle:before,
+.fa-google-plus-official:before {
+ content: "\f2b3";
+}
+.fa-fa:before,
+.fa-font-awesome:before {
+ content: "\f2b4";
+}
+.fa-handshake-o:before {
+ content: "\f2b5";
+}
+.fa-envelope-open:before {
+ content: "\f2b6";
+}
+.fa-envelope-open-o:before {
+ content: "\f2b7";
+}
+.fa-linode:before {
+ content: "\f2b8";
+}
+.fa-address-book:before {
+ content: "\f2b9";
+}
+.fa-address-book-o:before {
+ content: "\f2ba";
+}
+.fa-vcard:before,
+.fa-address-card:before {
+ content: "\f2bb";
+}
+.fa-vcard-o:before,
+.fa-address-card-o:before {
+ content: "\f2bc";
+}
+.fa-user-circle:before {
+ content: "\f2bd";
+}
+.fa-user-circle-o:before {
+ content: "\f2be";
+}
+.fa-user-o:before {
+ content: "\f2c0";
+}
+.fa-id-badge:before {
+ content: "\f2c1";
+}
+.fa-drivers-license:before,
+.fa-id-card:before {
+ content: "\f2c2";
+}
+.fa-drivers-license-o:before,
+.fa-id-card-o:before {
+ content: "\f2c3";
+}
+.fa-quora:before {
+ content: "\f2c4";
+}
+.fa-free-code-camp:before {
+ content: "\f2c5";
+}
+.fa-telegram:before {
+ content: "\f2c6";
+}
+.fa-thermometer-4:before,
+.fa-thermometer:before,
+.fa-thermometer-full:before {
+ content: "\f2c7";
+}
+.fa-thermometer-3:before,
+.fa-thermometer-three-quarters:before {
+ content: "\f2c8";
+}
+.fa-thermometer-2:before,
+.fa-thermometer-half:before {
+ content: "\f2c9";
+}
+.fa-thermometer-1:before,
+.fa-thermometer-quarter:before {
+ content: "\f2ca";
+}
+.fa-thermometer-0:before,
+.fa-thermometer-empty:before {
+ content: "\f2cb";
+}
+.fa-shower:before {
+ content: "\f2cc";
+}
+.fa-bathtub:before,
+.fa-s15:before,
+.fa-bath:before {
+ content: "\f2cd";
+}
+.fa-podcast:before {
+ content: "\f2ce";
+}
+.fa-window-maximize:before {
+ content: "\f2d0";
+}
+.fa-window-minimize:before {
+ content: "\f2d1";
+}
+.fa-window-restore:before {
+ content: "\f2d2";
+}
+.fa-times-rectangle:before,
+.fa-window-close:before {
+ content: "\f2d3";
+}
+.fa-times-rectangle-o:before,
+.fa-window-close-o:before {
+ content: "\f2d4";
+}
+.fa-bandcamp:before {
+ content: "\f2d5";
+}
+.fa-grav:before {
+ content: "\f2d6";
+}
+.fa-etsy:before {
+ content: "\f2d7";
+}
+.fa-imdb:before {
+ content: "\f2d8";
+}
+.fa-ravelry:before {
+ content: "\f2d9";
+}
+.fa-eercast:before {
+ content: "\f2da";
+}
+.fa-microchip:before {
+ content: "\f2db";
+}
+.fa-snowflake-o:before {
+ content: "\f2dc";
+}
+.fa-superpowers:before {
+ content: "\f2dd";
+}
+.fa-wpexplorer:before {
+ content: "\f2de";
+}
+.fa-meetup:before {
+ content: "\f2e0";
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+.sr-only-focusable:active,
+.sr-only-focusable:focus {
+ position: static;
+ width: auto;
+ height: auto;
+ margin: 0;
+ overflow: visible;
+ clip: auto;
+}
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.eot b/frontend/src/Content/Fonts/fontawesome-webfont.eot
new file mode 100644
index 000000000..e9f60ca95
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.eot differ
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.svg b/frontend/src/Content/Fonts/fontawesome-webfont.svg
new file mode 100644
index 000000000..855c845e5
--- /dev/null
+++ b/frontend/src/Content/Fonts/fontawesome-webfont.svg
@@ -0,0 +1,2671 @@
+
+
+
+
+Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016
+ By ,,,
+Copyright Dave Gandy 2016. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.ttf b/frontend/src/Content/Fonts/fontawesome-webfont.ttf
new file mode 100644
index 000000000..35acda2fa
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.ttf differ
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.woff b/frontend/src/Content/Fonts/fontawesome-webfont.woff
new file mode 100644
index 000000000..400014a4b
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.woff differ
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.woff2 b/frontend/src/Content/Fonts/fontawesome-webfont.woff2
new file mode 100644
index 000000000..4d13fc604
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.woff2 differ
diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css
new file mode 100644
index 000000000..483c61ca8
--- /dev/null
+++ b/frontend/src/Content/Fonts/fonts.css
@@ -0,0 +1,27 @@
+@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');
+}
diff --git a/src/UI/Content/Images/404.png b/frontend/src/Content/Images/404.png
similarity index 100%
rename from src/UI/Content/Images/404.png
rename to frontend/src/Content/Images/404.png
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..1d08e879e
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..36231e3f3
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-16x16.png differ
diff --git a/src/UI/Content/Images/logos/32.png b/frontend/src/Content/Images/Icons/favicon-32x32.png
similarity index 100%
rename from src/UI/Content/Images/logos/32.png
rename to frontend/src/Content/Images/Icons/favicon-32x32.png
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/src/UI/Content/Images/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico
similarity index 100%
rename from src/UI/Content/Images/favicon.ico
rename to frontend/src/Content/Images/Icons/favicon.ico
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..ce499be50
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/logo.svg b/frontend/src/Content/Images/logo.svg
new file mode 100644
index 000000000..daa9904e0
--- /dev/null
+++ b/frontend/src/Content/Images/logo.svg
@@ -0,0 +1 @@
+ background Layer 1
\ No newline at end of file
diff --git a/src/UI/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png
similarity index 100%
rename from src/UI/Content/Images/poster-dark.png
rename to frontend/src/Content/Images/poster-dark.png
diff --git a/frontend/src/Episode/EpisodeDetailsModal.js b/frontend/src/Episode/EpisodeDetailsModal.js
new file mode 100644
index 000000000..945c2fb8e
--- /dev/null
+++ b/frontend/src/Episode/EpisodeDetailsModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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..4c690eb6a
--- /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 0 10px;
+ 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;
+}
+
+.tabPanel {
+ 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..1459a9bce
--- /dev/null
+++ b/frontend/src/Episode/EpisodeDetailsModalContent.js
@@ -0,0 +1,213 @@
+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,
+ artistId,
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ showOpenSeriesButton &&
+
+ Open Series
+
+ }
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+EpisodeDetailsModalContent.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ episodeEntity: PropTypes.string.isRequired,
+ episodeFileId: PropTypes.number,
+ artistId: 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..59129e142
--- /dev/null
+++ b/frontend/src/Episode/EpisodeDetailsModalContentConnector.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 { clearReleases } from 'Store/Actions/releaseActions';
+import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import episodeEntities from 'Episode/episodeEntities';
+import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeSelector(),
+ createArtistSelector(),
+ (episode, series) => {
+ const {
+ title: seriesTitle,
+ titleSlug,
+ monitored: seriesMonitored,
+ seriesType
+ } = series;
+
+ return {
+ seriesTitle,
+ titleSlug,
+ seriesMonitored,
+ seriesType,
+ ...episode
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ clearReleases,
+ toggleEpisodeMonitored
+};
+
+class EpisodeDetailsModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentWillUnmount() {
+ // Clear pending releases here so we can reshow the search
+ // results even after switching tabs.
+
+ this.props.clearReleases();
+ }
+
+ //
+ // Listeners
+
+ onMonitorEpisodePress = (monitored) => {
+ const {
+ episodeId,
+ episodeEntity
+ } = this.props;
+
+ this.props.toggleEpisodeMonitored({
+ episodeEntity,
+ episodeId,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EpisodeDetailsModalContentConnector.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ episodeEntity: PropTypes.string.isRequired,
+ artistId: PropTypes.number.isRequired,
+ clearReleases: PropTypes.func.isRequired,
+ toggleEpisodeMonitored: PropTypes.func.isRequired
+};
+
+EpisodeDetailsModalContentConnector.defaultProps = {
+ episodeEntity: episodeEntities.EPISODES
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeDetailsModalContentConnector);
diff --git a/frontend/src/Episode/EpisodeLanguage.js b/frontend/src/Episode/EpisodeLanguage.js
new file mode 100644
index 000000000..fc784ef51
--- /dev/null
+++ b/frontend/src/Episode/EpisodeLanguage.js
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Label from 'Components/Label';
+
+function EpisodeLanguage(props) {
+ const language = props.language;
+
+ if (!language) {
+ return null;
+ }
+
+ return (
+
+ {language.name}
+
+ );
+}
+
+EpisodeLanguage.propTypes = {
+ language: PropTypes.object
+};
+
+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..9df84111e
--- /dev/null
+++ b/frontend/src/Episode/EpisodeNumber.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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 EpisodeNumber(props) {
+ const {
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ unverifiedSceneNumbering,
+ alternateTitles,
+ seriesType
+ } = props;
+
+ const hasSceneInformation = sceneSeasonNumber !== undefined ||
+ sceneEpisodeNumber !== undefined ||
+ (seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) ||
+ !!alternateTitles.length;
+
+ return (
+
+ {
+ hasSceneInformation ?
+
+ {episodeNumber}
+
+ {
+ seriesType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ }
+ title="Scene Information"
+ body={
+
+ }
+ position={tooltipPositions.RIGHT}
+ /> :
+
+ {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
+};
+
+EpisodeNumber.defaultProps = {
+ unverifiedSceneNumbering: false
+};
+
+export default EpisodeNumber;
diff --git a/frontend/src/Episode/EpisodeQuality.js b/frontend/src/Episode/EpisodeQuality.js
new file mode 100644
index 000000000..1202f8706
--- /dev/null
+++ b/frontend/src/Episode/EpisodeQuality.js
@@ -0,0 +1,54 @@
+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 {
+ title,
+ quality,
+ size,
+ isCutoffNotMet
+ } = props;
+
+ return (
+
+ {quality.quality.name}
+
+ );
+}
+
+EpisodeQuality.propTypes = {
+ 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..de6b551c4
--- /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,
+ artistId,
+ episodeTitle,
+ isSearching,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+EpisodeSearchCell.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ artistId: 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..cc4cce8ee
--- /dev/null
+++ b/frontend/src/Episode/EpisodeSearchCellConnector.js
@@ -0,0 +1,47 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+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,
+ createArtistSelector(),
+ createCommandsSelector(),
+ (episodeId, sceneSeasonNumber, series, commands) => {
+ const isSearching = _.some(commands, (command) => {
+ const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
+
+ if (!episodeSearch) {
+ return false;
+ }
+
+ return 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..aa0d923bd
--- /dev/null
+++ b/frontend/src/Episode/EpisodeStatus.js
@@ -0,0 +1,127 @@
+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..fc284096e
--- /dev/null
+++ b/frontend/src/Episode/History/EpisodeHistory.js
@@ -0,0 +1,112 @@
+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: '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..3d3be61ed
--- /dev/null
+++ b/frontend/src/Episode/History/EpisodeHistoryRow.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+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 EpisodeQuality from 'Episode/EpisodeQuality';
+import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
+import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
+import styles from './EpisodeHistoryRow.css';
+
+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,
+ quality,
+ qualityCutoffNotMet,
+ date,
+ data
+ } = this.props;
+
+ const {
+ isMarkAsFailedModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+ {sourceTitle}
+
+
+
+
+
+
+
+
+
+
+ }
+ title={titleCase(eventType)}
+ body={
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+EpisodeHistoryRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.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..af4908d4d
--- /dev/null
+++ b/frontend/src/Episode/SceneInfo.css
@@ -0,0 +1,11 @@
+.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..b406bb242
--- /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..748cb9660
--- /dev/null
+++ b/frontend/src/Episode/Search/EpisodeSearchConnector.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 { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import EpisodeSearch from './EpisodeSearch';
+import InteractiveEpisodeSearchConnector from './InteractiveEpisodeSearchConnector';
+
+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() {
+ 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/Search/InteractiveEpisodeSearch.js b/frontend/src/Episode/Search/InteractiveEpisodeSearch.js
new file mode 100644
index 000000000..eb8f8493f
--- /dev/null
+++ b/frontend/src/Episode/Search/InteractiveEpisodeSearch.js
@@ -0,0 +1,130 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Icon from 'Components/Icon';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import InteractiveEpisodeSearchRow from './InteractiveEpisodeSearchRow';
+
+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: 'qualityWeight',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, { name: icons.DANGER }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ },
+ {
+ name: 'releaseWeight',
+ label: React.createElement(Icon, { name: icons.DOWNLOAD }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ }
+];
+
+function InteractiveEpisodeSearch(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ longDateFormat,
+ timeFormat,
+ onSortPress,
+ onGrabPress
+ } = props;
+
+ if (isFetching) {
+ return ;
+ } else if (!isFetching && !!error) {
+ return Unable to load results for this episode search. Try again later.
;
+ } else if (isPopulated && !items.length) {
+ return No results found.
;
+ }
+
+ return (
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+}
+
+InteractiveEpisodeSearch.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onGrabPress: PropTypes.func.isRequired
+};
+
+export default InteractiveEpisodeSearch;
diff --git a/frontend/src/Episode/Search/InteractiveEpisodeSearchConnector.js b/frontend/src/Episode/Search/InteractiveEpisodeSearchConnector.js
new file mode 100644
index 000000000..20c93813c
--- /dev/null
+++ b/frontend/src/Episode/Search/InteractiveEpisodeSearchConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import { fetchReleases, setReleasesSort, grabRelease } from 'Store/Actions/releaseActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import InteractiveEpisodeSearch from './InteractiveEpisodeSearch';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ createUISettingsSelector(),
+ (releases, uiSettings) => {
+ return {
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ ...releases
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchReleases,
+ setReleasesSort,
+ grabRelease
+};
+
+class InteractiveEpisodeSearchConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ episodeId,
+ isPopulated
+ } = this.props;
+
+ // If search results are not yet isPopulated fetch them,
+ // otherwise re-show the existing props.
+
+ if (!isPopulated) {
+ this.props.fetchReleases({
+ episodeId
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setReleasesSort({ sortKey, sortDirection });
+ }
+
+ onGrabPress = (guid) => {
+ this.props.grabRelease({ guid });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+InteractiveEpisodeSearchConnector.propTypes = {
+ episodeId: PropTypes.number.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchReleases: PropTypes.func.isRequired,
+ setReleasesSort: PropTypes.func.isRequired,
+ grabRelease: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'releases' }
+ )(InteractiveEpisodeSearchConnector);
diff --git a/frontend/src/Episode/Search/InteractiveEpisodeSearchRow.css b/frontend/src/Episode/Search/InteractiveEpisodeSearchRow.css
new file mode 100644
index 000000000..c77b73e7d
--- /dev/null
+++ b/frontend/src/Episode/Search/InteractiveEpisodeSearchRow.css
@@ -0,0 +1,25 @@
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
+
+.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/Episode/Search/InteractiveEpisodeSearchRow.js b/frontend/src/Episode/Search/InteractiveEpisodeSearchRow.js
new file mode 100644
index 000000000..4d32606cc
--- /dev/null
+++ b/frontend/src/Episode/Search/InteractiveEpisodeSearchRow.js
@@ -0,0 +1,197 @@
+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 EpisodeQuality from 'Episode/EpisodeQuality';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import Peers from './Peers';
+import styles from './InteractiveEpisodeSearchRow.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 InteractiveEpisodeSearchRow extends Component {
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ this.props.onGrabPress(this.props.guid);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ protocol,
+ age,
+ ageHours,
+ ageMinutes,
+ publishDate,
+ title,
+ infoUrl,
+ indexer,
+ size,
+ seeders,
+ leechers,
+ quality,
+ rejections,
+ downloadAllowed,
+ isGrabbing,
+ isGrabbed,
+ longDateFormat,
+ timeFormat,
+ grabError
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+ {formatAge(age, ageHours, ageMinutes)}
+
+
+
+
+ {title}
+
+
+
+
+ {indexer}
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ protocol === 'torrent' &&
+
+ }
+
+
+
+
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+ {
+ downloadAllowed &&
+
+ }
+
+
+ );
+ }
+}
+
+InteractiveEpisodeSearchRow.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,
+ indexer: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ seeders: PropTypes.number,
+ leechers: PropTypes.number,
+ quality: PropTypes.object.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
+};
+
+InteractiveEpisodeSearchRow.defaultProps = {
+ isGrabbing: false,
+ isGrabbed: false
+};
+
+export default InteractiveEpisodeSearchRow;
diff --git a/frontend/src/Episode/Search/Peers.js b/frontend/src/Episode/Search/Peers.js
new file mode 100644
index 000000000..66f7cc9f5
--- /dev/null
+++ b/frontend/src/Episode/Search/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/Episode/SeasonEpisodeNumber.css b/frontend/src/Episode/SeasonEpisodeNumber.css
new file mode 100644
index 000000000..f86e1de6b
--- /dev/null
+++ b/frontend/src/Episode/SeasonEpisodeNumber.css
@@ -0,0 +1,3 @@
+.absoluteEpisodeNumber {
+ margin-left: 5px;
+}
diff --git a/frontend/src/Episode/SeasonEpisodeNumber.js b/frontend/src/Episode/SeasonEpisodeNumber.js
new file mode 100644
index 000000000..b0c9d3ee6
--- /dev/null
+++ b/frontend/src/Episode/SeasonEpisodeNumber.js
@@ -0,0 +1,51 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import padNumber from 'Utilities/Number/padNumber';
+import styles from './SeasonEpisodeNumber.css';
+
+function SeasonEpisodeNumber(props) {
+ const {
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDate,
+ seriesType
+ } = props;
+
+ if (seriesType === 'daily' && airDate) {
+ return (
+ {airDate}
+ );
+ }
+
+ if (seriesType === 'anime') {
+ return (
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ {
+ absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ );
+ }
+
+ return (
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ );
+}
+
+SeasonEpisodeNumber.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ 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..fb04508f3
--- /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 {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ flex: 1 0 1px;
+}
+
+.size,
+.quality {
+ flex: 0 0 125px;
+}
+
+.actions {
+ flex: 0 0 20px;
+ 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..8b8388744
--- /dev/null
+++ b/frontend/src/Episode/Summary/EpisodeSummary.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Label from 'Components/Label';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import EpisodeQuality from 'Episode/EpisodeQuality';
+import EpisodeAiringConnector from './EpisodeAiringConnector';
+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,
+ 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)}
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+ );
+ }
+}
+
+EpisodeSummary.propTypes = {
+ episodeFileId: PropTypes.number.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ network: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ airDateUtc: PropTypes.string.isRequired,
+ 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..0f5f1f132
--- /dev/null
+++ b/frontend/src/Episode/Summary/EpisodeSummaryConnector.js
@@ -0,0 +1,57 @@
+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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import EpisodeSummary from './EpisodeSummary';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createEpisodeSelector(),
+ createEpisodeFileSelector(),
+ (series, episode, episodeFile) => {
+ const {
+ qualityProfileId,
+ network
+ } = series;
+
+ const {
+ airDateUtc,
+ overview
+ } = episode;
+
+ const {
+ path,
+ size,
+ quality,
+ qualityCutoffNotMet
+ } = episodeFile || {};
+
+ return {
+ network,
+ qualityProfileId,
+ airDateUtc,
+ overview,
+ 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/episodeEntities.js b/frontend/src/Episode/episodeEntities.js
new file mode 100644
index 000000000..7e6ea91e9
--- /dev/null
+++ b/frontend/src/Episode/episodeEntities.js
@@ -0,0 +1,13 @@
+export const CALENDAR = 'calendar';
+export const EPISODES = 'episodes';
+export const QUEUE_EPISODES = 'queue.queueEpisodes';
+export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
+export const WANTED_MISSING = 'wanted.missing';
+
+export default {
+ CALENDAR,
+ EPISODES,
+ QUEUE_EPISODES,
+ 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..8c466798f
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js
@@ -0,0 +1,276 @@
+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 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 _.uniq(_.map(selectedIds, (id) => {
+ return _.find(this.props.items, { id }).episodeFileId;
+ }));
+ }
+
+ //
+ // 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 {
+ 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
+
+
+
+ {
+ !items.length &&
+
+ No episode files to manage.
+
+ }
+
+ {
+ !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+
+ );
+ }
+}
+
+EpisodeFileEditorModalContent.propTypes = {
+ 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..3f2fc77b5
--- /dev/null
+++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js
@@ -0,0 +1,162 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+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,
+ createArtistSelector(),
+ (
+ 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 = _.map(qualityProfileSchema.items, 'quality');
+
+ 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 }));
+ },
+
+
+ onQualityChange(episodeFileIds, qualityId) {
+ const quality = {
+ quality: _.find(this.props.qualities, { id: qualityId }),
+ revision: {
+ version: 1,
+ real: 0
+ }
+ };
+
+ dispatch(updateEpisodeFiles({ episodeFileIds, quality }));
+ }
+ };
+}
+
+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 = {
+ artistId: 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..eccb1ea6b
--- /dev/null
+++ b/frontend/src/EpisodeFile/MediaInfo.js
@@ -0,0 +1,53 @@
+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/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js
new file mode 100644
index 000000000..68663ebfc
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterTypes.js
@@ -0,0 +1,17 @@
+export const CONTAINS = 'contains';
+export const EQUAL = 'equal';
+export const GREATER_THAN = 'greaterThan';
+export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual';
+export const LESS_THAN = 'lessThan';
+export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual';
+export const NOT_EQUAL = 'notEqual';
+
+export const all = [
+ CONTAINS,
+ EQUAL,
+ GREATER_THAN,
+ GREATER_THAN_OR_EQUAL,
+ LESS_THAN,
+ LESS_THAN_OR_EQUAL,
+ NOT_EQUAL
+];
diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js
new file mode 100644
index 000000000..fbd0b8c6d
--- /dev/null
+++ b/frontend/src/Helpers/Props/icons.js
@@ -0,0 +1,88 @@
+export const ACTIONS = 'fa fa-bolt';
+export const ACTIVITY = 'fa fa-clock-o';
+export const ADD = 'fa fa-plus';
+export const ALTERNATE_TITLES = 'fa fa-clone';
+export const ADVANCED_SETTINGS = 'fa fa-cog';
+export const ARROW_LEFT = 'fa fa-arrow-circle-left';
+export const ARROW_RIGHT = 'fa fa-arrow-circle-right';
+export const BACKUP = 'fa fa-file-archive-o';
+export const BUG = 'fa fa-bug';
+export const CALENDAR = 'fa fa-calendar';
+export const CALENDAR_O = 'fa fa-calendar-o';
+export const CARET_DOWN = 'fa fa-caret-down';
+export const CHECK = 'fa fa-check';
+export const CHECK_INDETERMINATE = 'fa fa-minus';
+export const CHECK_CIRCLE = 'fa fa-check-circle';
+export const CIRCLE_OUTLINE = 'fa fa-circle-o';
+export const CLEAR = 'fa fa-trash';
+export const CLIPBOARD = 'fa fa-clipboard';
+export const CLOSE = 'fa fa-times';
+export const COLLAPSE = 'fa fa-chevron-circle-up';
+export const COMPUTER = 'fa fa-desktop';
+export const DANGER = 'fa fa-exclamation-circle';
+export const DELETE = 'fa fa-trash';
+export const DOWNLOAD = 'fa fa-download';
+export const DOWNLOADED = 'fa fa-inbox';
+export const DOWNLOADING = 'fa fa-cloud-download';
+export const DRIVE = 'fa fa-hdd-o';
+export const EDIT = 'fa fa-wrench';
+export const EPISODE_FILE = 'fa fa-file-video-o';
+export const EXPAND = 'fa fa-chevron-circle-down';
+export const EXPAND_INDETERMINATE = 'fa fa-chevron-circle-right';
+export const EXTERNAL_LINK = 'fa fa-external-link';
+export const FATAL = 'fa fa-times-circle';
+export const FILE = 'fa fa-file-o';
+export const FILTER = 'fa fa-filter';
+export const FOLDER = 'fa fa-folder-o';
+export const FOLDER_OPEN = 'fa fa-folder-open';
+export const HEALTH = 'fa fa-medkit';
+export const HEART = 'fa fa-heart';
+export const HOUSEKEEPING = 'fa fa-home';
+export const INFO = 'fa fa-info-circle';
+export const INTERACTIVE = 'fa fa-user';
+export const LOGOUT = 'fa fa-sign-out';
+export const MISSING = 'fa fa-exclamation-triangle';
+export const MONITORED = 'fa fa-bookmark';
+export const NETWORK = 'fa fa-signal';
+export const NAVBAR_COLLAPSE = 'fa fa-bars';
+export const NOT_AIRED = 'fa fa-clock-o';
+export const ORGANIZE = 'fa fa-sitemap';
+export const OVERFLOW = 'fa fa-ellipsis-h';
+export const PAGE_FIRST = 'fa fa-fast-backward';
+export const PAGE_PREVIOUS = 'fa fa-backward';
+export const PAGE_NEXT = 'fa fa-forward';
+export const PAGE_LAST = 'fa fa-fast-forward';
+export const PARENT = 'fa fa-level-up';
+export const PAUSED = 'fa fa-pause';
+export const PENDING = 'fa fa-clock-o';
+export const PROFILE = 'fa fa-user';
+export const POSTER = 'fa fa-th';
+export const QUEUED = 'fa fa-cloud';
+export const QUICK = 'fa fa-rocket';
+export const REFRESH = 'fa fa-refresh';
+export const REMOVE = 'fa fa-remove';
+export const RESTART = 'fa fa-repeat';
+export const REORDER = 'fa fa-bars';
+export const RSS = 'fa fa-rss';
+export const SAVE = 'fa fa-floppy-o';
+export const SCHEDULED = 'fa fa-clock-o';
+export const SEARCH = 'fa fa-search';
+export const SERIES_CONTINUING = 'fa fa-play';
+export const SERIES_ENDED = 'fa fa-stop';
+export const SETTINGS = 'fa fa-cogs';
+export const SHUTDOWN = 'fa fa-power-off';
+export const SORT = 'fa fa-sort';
+export const SORT_ASCENDING = 'fa fa-sort-asc';
+export const SORT_DESCENDING = 'fa fa-sort-desc';
+export const SPIN = 'fa-spin';
+export const SPINNER = 'fa fa-spinner';
+export const SUBTRACT = 'fa fa-minus';
+export const SYSTEM = 'fa fa-laptop';
+export const TAGS = 'fa fa-tags';
+export const TBA = 'fa fa-question-circle';
+export const UNKNOWN = 'fa fa-question';
+export const UNMONITORED = 'fa fa-bookmark-o';
+export const UPDATE = 'fa fa-retweet';
+export const UNSAVED_SETTING = 'fa fa-dot-circle-o';
+export const VIEW = 'fa fa-eye';
+export const WARNING = 'fa fa-exclamation-triangle';
diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js
new file mode 100644
index 000000000..0a989a26f
--- /dev/null
+++ b/frontend/src/Helpers/Props/index.js
@@ -0,0 +1,23 @@
+import * as align from './align';
+import * as inputTypes from './inputTypes';
+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,
+ 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..b980dccaf
--- /dev/null
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -0,0 +1,33 @@
+export const CAPTCHA = 'captcha';
+export const CHECK = 'check';
+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 = [
+ CAPTCHA,
+ CHECK,
+ 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..cb2d5fabe
--- /dev/null
+++ b/frontend/src/Helpers/Props/kinds.js
@@ -0,0 +1,19 @@
+export const DANGER = 'danger';
+export const DEFAULT = 'default';
+export const INFO = 'info';
+export const INVERSE = 'inverse';
+export const PRIMARY = 'primary';
+export const PURPLE = 'purple';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ DANGER,
+ DEFAULT,
+ INFO,
+ INVERSE,
+ 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..e572e8fc8
--- /dev/null
+++ b/frontend/src/Helpers/Props/sizes.js
@@ -0,0 +1,5 @@
+export const SMALL = 'small';
+export const MEDIUM = 'medium';
+export const LARGE = 'large';
+
+export const all = [SMALL, MEDIUM, 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/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..c6b6154ff
--- /dev/null
+++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js
@@ -0,0 +1,183 @@
+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 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 = error && error.message || '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..e107e9dd5
--- /dev/null
+++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js
@@ -0,0 +1,103 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import { fetchEpisodes, setEpisodesSort, clearEpisodes } from 'Store/Actions/episodeActions';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import SelectEpisodeModalContent from './SelectEpisodeModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ (episodes) => {
+ return episodes;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchEpisodes,
+ setEpisodesSort,
+ clearEpisodes,
+ updateInteractiveImportItem
+};
+
+class SelectEpisodeModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId,
+ seasonNumber
+ } = this.props;
+
+ this.props.fetchEpisodes({ artistId, 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.clearEpisodes();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setEpisodesSort({ 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,
+ artistId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchEpisodes: PropTypes.func.isRequired,
+ setEpisodesSort: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'episodes' }
+ )(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..cabd33d7c
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js
@@ -0,0 +1,161 @@
+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'
+ }
+];
+
+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,
+ 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,
+ 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..2df7b1c4c
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addRecentFolder } 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,
+ 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);
+ }
+
+ //
+ // 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,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
new file mode 100644
index 000000000..bc32f5749
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+
+class RecentFolderRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ folder,
+ lastUsed
+ } = this.props;
+
+ return (
+
+ {folder}
+
+
+
+ );
+ }
+}
+
+RecentFolderRow.propTypes = {
+ folder: PropTypes.string.isRequired,
+ lastUsed: PropTypes.string.isRequired,
+ onPress: 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..964faac31
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
@@ -0,0 +1,45 @@
+.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 {
+ 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..108220c5e
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js
@@ -0,0 +1,320 @@
+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 { 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 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: 'size',
+ label: 'Size',
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, {
+ name: icons.DANGER,
+ kind: kinds.DANGER
+ }),
+ isVisible: true
+ }
+];
+
+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 selected = this.getSelectedIds();
+
+ this.props.onImportSelectedPress(selected, this.state.importMode);
+ }
+
+ 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,
+ 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 = error && error.message || 'Unable to load manual import items';
+
+ const importModeOptions = [
+ { key: 'move', value: 'Move Files' },
+ { key: 'copy', value: 'Copy Files' }
+ ];
+
+ return (
+
+
+ Manual Import - {title || folder}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+ {
+ isPopulated && !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length &&
+ 'No video files were found in the selected folder'
+ }
+
+
+
+ {
+ !downloadId &&
+
+
+
+ }
+
+
+
+ Select Series
+
+
+
+ Select Season
+
+
+
+
+
+ Cancel
+
+
+ {
+ interactiveImportErrorMessage &&
+ {interactiveImportErrorMessage}
+ }
+
+
+ Import
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+InteractiveImportModalContent.propTypes = {
+ downloadId: PropTypes.string,
+ 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,
+ onImportModeChange: PropTypes.func.isRequired,
+ onImportSelectedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContent.defaultProps = {
+ 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..030bb5c96
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
@@ -0,0 +1,152 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+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) => {
+ return interactiveImport;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportItems,
+ setInteractiveImportSort,
+ setInteractiveImportMode,
+ clearInteractiveImport,
+ executeCommand
+};
+
+class InteractiveImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ interactiveImportErrorMessage: null
+ };
+ }
+
+ componentDidMount() {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ this.props.fetchInteractiveImportItems({ downloadId, folder });
+ }
+
+ componentWillUnmount() {
+ this.props.clearInteractiveImport();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportSort({ sortKey, sortDirection });
+ }
+
+ 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
+ } = 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;
+ }
+
+ files.push({
+ path: item.path,
+ artistId: series.id,
+ episodeIds: _.map(episodes, 'id'),
+ quality,
+ downloadId: this.props.downloadId
+ });
+ }
+ });
+
+ if (!files.length) {
+ return;
+ }
+
+ this.props.executeCommand({
+ name: commandNames.INTERACTIVE_IMPORT,
+ files,
+ importMode
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+InteractiveImportModalContentConnector.propTypes = {
+ downloadId: PropTypes.string,
+ folder: PropTypes.string,
+ 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
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'interactiveImport' }
+ )(InteractiveImportModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
new file mode 100644
index 000000000..22234718f
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
@@ -0,0 +1,11 @@
+.relativePath {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
new file mode 100644
index 000000000..6638c7879
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
@@ -0,0 +1,294 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+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 SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
+import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
+import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
+import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
+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
+ };
+ }
+
+ componentDidMount() {
+ const {
+ id,
+ series,
+ seasonNumber,
+ episodes,
+ quality
+ } = this.props;
+
+ if (series && seasonNumber !== undefined && episodes.length && quality) {
+ this.props.onSelectedChange({ id, value: true });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ series,
+ seasonNumber,
+ episodes,
+ quality,
+ isSelected,
+ onValidRowChange
+ } = this.props;
+
+ if (prevProps.isSelected === isSelected) {
+ return;
+ }
+
+ const isValid = !!(series && seasonNumber != null && episodes.length && quality);
+
+ 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 });
+ }
+
+ 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);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ relativePath,
+ series,
+ seasonNumber,
+ episodes,
+ quality,
+ size,
+ rejections,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ const {
+ isSelectSeriesModalOpen,
+ isSelectSeasonModalOpen,
+ isSelectEpisodeModalOpen,
+ isSelectQualityModalOpen
+ } = 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;
+
+ return (
+
+
+
+
+ {relativePath}
+
+
+
+ {
+ showSeriesPlaceholder ? : seriesTitle
+ }
+
+
+
+ {
+ showSeasonNumberPlaceholder ? : seasonNumber
+ }
+
+
+
+ {
+ showEpisodeNumbersPlaceholder ? : episodeNumbers
+ }
+
+
+
+
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection.reason}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+
+
+
+
+
+ 1}
+ real={quality.revision.real > 0}
+ onModalClose={this.onSelectQualityModalClose}
+ />
+
+ );
+ }
+
+}
+
+InteractiveImportRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ relativePath: PropTypes.string.isRequired,
+ series: PropTypes.object,
+ seasonNumber: PropTypes.number,
+ episodes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ quality: 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..250106c0c
--- /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/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..8649763a9
--- /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(({ quality }) => {
+ return {
+ key: quality.id,
+ value: quality.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..e50b3af2e
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js
@@ -0,0 +1,94 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+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 {
+ isFetchingSchema: isFetching,
+ schemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = qualityProfiles;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items: 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.quality.id === qualityId).quality;
+
+ 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..65304b339
--- /dev/null
+++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js
@@ -0,0 +1,62 @@
+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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import SelectSeasonModalContent from './SelectSeasonModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (series) => {
+ return {
+ items: series.seasons
+ };
+ }
+ );
+}
+
+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,
+ artistId: 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..d297be072
--- /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: text 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..e7456d73f
--- /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..daf7ca3f6
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js
@@ -0,0 +1,65 @@
+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
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateInteractiveImportItem
+};
+
+class SelectSeriesModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onSeriesSelect = (artistId) => {
+ const series = _.find(this.props.items, { id: artistId });
+
+ 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/JsLibraries/jquery.js b/frontend/src/JsLibraries/jquery.js
new file mode 100644
index 000000000..aa4d89cb6
--- /dev/null
+++ b/frontend/src/JsLibraries/jquery.js
@@ -0,0 +1,9842 @@
+/*!
+ * jQuery JavaScript Library v2.2.2
+ * http://jquery.com/
+ *
+ * Includes Sizzle.js
+ * http://sizzlejs.com/
+ *
+ * Copyright jQuery Foundation and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2016-03-17T17:51Z
+ */
+
+(function( global, factory ) {
+
+ if ( typeof module === "object" && typeof module.exports === "object" ) {
+ // For CommonJS and CommonJS-like environments where a proper `window`
+ // is present, execute the factory and get jQuery.
+ // For environments that do not have a `window` with a `document`
+ // (such as Node.js), expose a factory as module.exports.
+ // This accentuates the need for the creation of a real `window`.
+ // e.g. var jQuery = require("jquery")(window);
+ // See ticket #14549 for more info.
+ module.exports = global.document ?
+ factory( global, true ) :
+ function( w ) {
+ if ( !w.document ) {
+ throw new Error( "jQuery requires a window with a document" );
+ }
+ return factory( w );
+ };
+ } else {
+ factory( global );
+ }
+
+// Pass this if window is not defined yet
+}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) {
+
+// Support: Firefox 18+
+// Can't be in strict mode, several libs including ASP.NET trace
+// the stack via arguments.caller.callee and Firefox dies if
+// you try to trace through "use strict" call chains. (#13335)
+//"use strict";
+var arr = [];
+
+var document = window.document;
+
+var slice = arr.slice;
+
+var concat = arr.concat;
+
+var push = arr.push;
+
+var indexOf = arr.indexOf;
+
+var class2type = {};
+
+var toString = class2type.toString;
+
+var hasOwn = class2type.hasOwnProperty;
+
+var support = {};
+
+
+
+var
+ version = "2.2.2",
+
+ // Define a local copy of jQuery
+ jQuery = function( selector, context ) {
+
+ // The jQuery object is actually just the init constructor 'enhanced'
+ // Need init if jQuery is called (just allow error to be thrown if not included)
+ return new jQuery.fn.init( selector, context );
+ },
+
+ // Support: Android<4.1
+ // Make sure we trim BOM and NBSP
+ rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,
+
+ // Matches dashed string for camelizing
+ rmsPrefix = /^-ms-/,
+ rdashAlpha = /-([\da-z])/gi,
+
+ // Used by jQuery.camelCase as callback to replace()
+ fcamelCase = function( all, letter ) {
+ return letter.toUpperCase();
+ };
+
+jQuery.fn = jQuery.prototype = {
+
+ // The current version of jQuery being used
+ jquery: version,
+
+ constructor: jQuery,
+
+ // Start with an empty selector
+ selector: "",
+
+ // The default length of a jQuery object is 0
+ length: 0,
+
+ toArray: function() {
+ return slice.call( this );
+ },
+
+ // Get the Nth element in the matched element set OR
+ // Get the whole matched element set as a clean array
+ get: function( num ) {
+ return num != null ?
+
+ // Return just the one element from the set
+ ( num < 0 ? this[ num + this.length ] : this[ num ] ) :
+
+ // Return all the elements in a clean array
+ slice.call( this );
+ },
+
+ // Take an array of elements and push it onto the stack
+ // (returning the new matched element set)
+ pushStack: function( elems ) {
+
+ // Build a new jQuery matched element set
+ var ret = jQuery.merge( this.constructor(), elems );
+
+ // Add the old object onto the stack (as a reference)
+ ret.prevObject = this;
+ ret.context = this.context;
+
+ // Return the newly-formed element set
+ return ret;
+ },
+
+ // Execute a callback for every element in the matched set.
+ each: function( callback ) {
+ return jQuery.each( this, callback );
+ },
+
+ map: function( callback ) {
+ return this.pushStack( jQuery.map( this, function( elem, i ) {
+ return callback.call( elem, i, elem );
+ } ) );
+ },
+
+ slice: function() {
+ return this.pushStack( slice.apply( this, arguments ) );
+ },
+
+ first: function() {
+ return this.eq( 0 );
+ },
+
+ last: function() {
+ return this.eq( -1 );
+ },
+
+ eq: function( i ) {
+ var len = this.length,
+ j = +i + ( i < 0 ? len : 0 );
+ return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] );
+ },
+
+ end: function() {
+ return this.prevObject || this.constructor();
+ },
+
+ // For internal use only.
+ // Behaves like an Array's method, not like a jQuery method.
+ push: push,
+ sort: arr.sort,
+ splice: arr.splice
+};
+
+jQuery.extend = jQuery.fn.extend = function() {
+ var options, name, src, copy, copyIsArray, clone,
+ target = arguments[ 0 ] || {},
+ i = 1,
+ length = arguments.length,
+ deep = false;
+
+ // Handle a deep copy situation
+ if ( typeof target === "boolean" ) {
+ deep = target;
+
+ // Skip the boolean and the target
+ target = arguments[ i ] || {};
+ i++;
+ }
+
+ // Handle case when target is a string or something (possible in deep copy)
+ if ( typeof target !== "object" && !jQuery.isFunction( target ) ) {
+ target = {};
+ }
+
+ // Extend jQuery itself if only one argument is passed
+ if ( i === length ) {
+ target = this;
+ i--;
+ }
+
+ for ( ; i < length; i++ ) {
+
+ // Only deal with non-null/undefined values
+ if ( ( options = arguments[ i ] ) != null ) {
+
+ // Extend the base object
+ for ( name in options ) {
+ src = target[ name ];
+ copy = options[ name ];
+
+ // Prevent never-ending loop
+ if ( target === copy ) {
+ continue;
+ }
+
+ // Recurse if we're merging plain objects or arrays
+ if ( deep && copy && ( jQuery.isPlainObject( copy ) ||
+ ( copyIsArray = jQuery.isArray( copy ) ) ) ) {
+
+ if ( copyIsArray ) {
+ copyIsArray = false;
+ clone = src && jQuery.isArray( src ) ? src : [];
+
+ } else {
+ clone = src && jQuery.isPlainObject( src ) ? src : {};
+ }
+
+ // Never move original objects, clone them
+ target[ name ] = jQuery.extend( deep, clone, copy );
+
+ // Don't bring in undefined values
+ } else if ( copy !== undefined ) {
+ target[ name ] = copy;
+ }
+ }
+ }
+ }
+
+ // Return the modified object
+ return target;
+};
+
+jQuery.extend( {
+
+ // Unique for each copy of jQuery on the page
+ expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ),
+
+ // Assume jQuery is ready without the ready module
+ isReady: true,
+
+ error: function( msg ) {
+ throw new Error( msg );
+ },
+
+ noop: function() {},
+
+ isFunction: function( obj ) {
+ return jQuery.type( obj ) === "function";
+ },
+
+ isArray: Array.isArray,
+
+ isWindow: function( obj ) {
+ return obj != null && obj === obj.window;
+ },
+
+ isNumeric: function( obj ) {
+
+ // parseFloat NaNs numeric-cast false positives (null|true|false|"")
+ // ...but misinterprets leading-number strings, particularly hex literals ("0x...")
+ // subtraction forces infinities to NaN
+ // adding 1 corrects loss of precision from parseFloat (#15100)
+ var realStringObj = obj && obj.toString();
+ return !jQuery.isArray( obj ) && ( realStringObj - parseFloat( realStringObj ) + 1 ) >= 0;
+ },
+
+ isPlainObject: function( obj ) {
+ var key;
+
+ // Not plain objects:
+ // - Any object or value whose internal [[Class]] property is not "[object Object]"
+ // - DOM nodes
+ // - window
+ if ( jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) {
+ return false;
+ }
+
+ // Not own constructor property must be Object
+ if ( obj.constructor &&
+ !hasOwn.call( obj, "constructor" ) &&
+ !hasOwn.call( obj.constructor.prototype || {}, "isPrototypeOf" ) ) {
+ return false;
+ }
+
+ // Own properties are enumerated firstly, so to speed up,
+ // if last one is own, then all properties are own
+ for ( key in obj ) {}
+
+ return key === undefined || hasOwn.call( obj, key );
+ },
+
+ isEmptyObject: function( obj ) {
+ var name;
+ for ( name in obj ) {
+ return false;
+ }
+ return true;
+ },
+
+ type: function( obj ) {
+ if ( obj == null ) {
+ return obj + "";
+ }
+
+ // Support: Android<4.0, iOS<6 (functionish RegExp)
+ return typeof obj === "object" || typeof obj === "function" ?
+ class2type[ toString.call( obj ) ] || "object" :
+ typeof obj;
+ },
+
+ // Evaluates a script in a global context
+ globalEval: function( code ) {
+ var script,
+ indirect = eval;
+
+ code = jQuery.trim( code );
+
+ if ( code ) {
+
+ // If the code includes a valid, prologue position
+ // strict mode pragma, execute code by injecting a
+ // script tag into the document.
+ if ( code.indexOf( "use strict" ) === 1 ) {
+ script = document.createElement( "script" );
+ script.text = code;
+ document.head.appendChild( script ).parentNode.removeChild( script );
+ } else {
+
+ // Otherwise, avoid the DOM node creation, insertion
+ // and removal by using an indirect global eval
+
+ indirect( code );
+ }
+ }
+ },
+
+ // Convert dashed to camelCase; used by the css and data modules
+ // Support: IE9-11+
+ // Microsoft forgot to hump their vendor prefix (#9572)
+ camelCase: function( string ) {
+ return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase );
+ },
+
+ nodeName: function( elem, name ) {
+ return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase();
+ },
+
+ each: function( obj, callback ) {
+ var length, i = 0;
+
+ if ( isArrayLike( obj ) ) {
+ length = obj.length;
+ for ( ; i < length; i++ ) {
+ if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {
+ break;
+ }
+ }
+ } else {
+ for ( i in obj ) {
+ if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {
+ break;
+ }
+ }
+ }
+
+ return obj;
+ },
+
+ // Support: Android<4.1
+ trim: function( text ) {
+ return text == null ?
+ "" :
+ ( text + "" ).replace( rtrim, "" );
+ },
+
+ // results is for internal usage only
+ makeArray: function( arr, results ) {
+ var ret = results || [];
+
+ if ( arr != null ) {
+ if ( isArrayLike( Object( arr ) ) ) {
+ jQuery.merge( ret,
+ typeof arr === "string" ?
+ [ arr ] : arr
+ );
+ } else {
+ push.call( ret, arr );
+ }
+ }
+
+ return ret;
+ },
+
+ inArray: function( elem, arr, i ) {
+ return arr == null ? -1 : indexOf.call( arr, elem, i );
+ },
+
+ merge: function( first, second ) {
+ var len = +second.length,
+ j = 0,
+ i = first.length;
+
+ for ( ; j < len; j++ ) {
+ first[ i++ ] = second[ j ];
+ }
+
+ first.length = i;
+
+ return first;
+ },
+
+ grep: function( elems, callback, invert ) {
+ var callbackInverse,
+ matches = [],
+ i = 0,
+ length = elems.length,
+ callbackExpect = !invert;
+
+ // Go through the array, only isSaving the items
+ // that pass the validator function
+ for ( ; i < length; i++ ) {
+ callbackInverse = !callback( elems[ i ], i );
+ if ( callbackInverse !== callbackExpect ) {
+ matches.push( elems[ i ] );
+ }
+ }
+
+ return matches;
+ },
+
+ // arg is for internal usage only
+ map: function( elems, callback, arg ) {
+ var length, value,
+ i = 0,
+ ret = [];
+
+ // Go through the array, translating each of the items to their new values
+ if ( isArrayLike( elems ) ) {
+ length = elems.length;
+ for ( ; i < length; i++ ) {
+ value = callback( elems[ i ], i, arg );
+
+ if ( value != null ) {
+ ret.push( value );
+ }
+ }
+
+ // Go through every key on the object,
+ } else {
+ for ( i in elems ) {
+ value = callback( elems[ i ], i, arg );
+
+ if ( value != null ) {
+ ret.push( value );
+ }
+ }
+ }
+
+ // Flatten any nested arrays
+ return concat.apply( [], ret );
+ },
+
+ // A global GUID counter for objects
+ guid: 1,
+
+ // Bind a function to a context, optionally partially applying any
+ // arguments.
+ proxy: function( fn, context ) {
+ var tmp, args, proxy;
+
+ if ( typeof context === "string" ) {
+ tmp = fn[ context ];
+ context = fn;
+ fn = tmp;
+ }
+
+ // Quick check to determine if target is callable, in the spec
+ // this throws a TypeError, but we will just return undefined.
+ if ( !jQuery.isFunction( fn ) ) {
+ return undefined;
+ }
+
+ // Simulated bind
+ args = slice.call( arguments, 2 );
+ proxy = function() {
+ return fn.apply( context || this, args.concat( slice.call( arguments ) ) );
+ };
+
+ // Set the guid of unique handler to the same of original handler, so it can be removed
+ proxy.guid = fn.guid = fn.guid || jQuery.guid++;
+
+ return proxy;
+ },
+
+ now: Date.now,
+
+ // jQuery.support is not used in Core but other projects attach their
+ // properties to it so it needs to exist.
+ support: support
+} );
+
+// JSHint would error on this code due to the Symbol not being defined in ES5.
+// Defining this global in .jshintrc would create a danger of using the global
+// unguarded in another place, it seems safer to just disable JSHint for these
+// three lines.
+/* jshint ignore: start */
+if ( typeof Symbol === "function" ) {
+ jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ];
+}
+/* jshint ignore: end */
+
+// Populate the class2type map
+jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ),
+function( i, name ) {
+ class2type[ "[object " + name + "]" ] = name.toLowerCase();
+} );
+
+function isArrayLike( obj ) {
+
+ // Support: iOS 8.2 (not reproducible in simulator)
+ // `in` check used to prevent JIT error (gh-2145)
+ // hasOwn isn't used here due to false negatives
+ // regarding Nodelist length in IE
+ var length = !!obj && "length" in obj && obj.length,
+ type = jQuery.type( obj );
+
+ if ( type === "function" || jQuery.isWindow( obj ) ) {
+ return false;
+ }
+
+ return type === "array" || length === 0 ||
+ typeof length === "number" && length > 0 && ( length - 1 ) in obj;
+}
+var Sizzle =
+/*!
+ * Sizzle CSS Selector Engine v2.2.1
+ * http://sizzlejs.com/
+ *
+ * Copyright jQuery Foundation and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2015-10-17
+ */
+(function( window ) {
+
+var i,
+ support,
+ Expr,
+ getText,
+ isXML,
+ tokenize,
+ compile,
+ select,
+ outermostContext,
+ sortInput,
+ hasDuplicate,
+
+ // Local document vars
+ setDocument,
+ document,
+ docElem,
+ documentIsHTML,
+ rbuggyQSA,
+ rbuggyMatches,
+ matches,
+ contains,
+
+ // Instance-specific data
+ expando = "sizzle" + 1 * new Date(),
+ preferredDoc = window.document,
+ dirruns = 0,
+ done = 0,
+ classCache = createCache(),
+ tokenCache = createCache(),
+ compilerCache = createCache(),
+ sortOrder = function( a, b ) {
+ if ( a === b ) {
+ hasDuplicate = true;
+ }
+ return 0;
+ },
+
+ // General-purpose constants
+ MAX_NEGATIVE = 1 << 31,
+
+ // Instance methods
+ hasOwn = ({}).hasOwnProperty,
+ arr = [],
+ pop = arr.pop,
+ push_native = arr.push,
+ push = arr.push,
+ slice = arr.slice,
+ // Use a stripped-down indexOf as it's faster than native
+ // http://jsperf.com/thor-indexof-vs-for/5
+ indexOf = function( list, elem ) {
+ var i = 0,
+ len = list.length;
+ for ( ; i < len; i++ ) {
+ if ( list[i] === elem ) {
+ return i;
+ }
+ }
+ return -1;
+ },
+
+ booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",
+
+ // Regular expressions
+
+ // http://www.w3.org/TR/css3-selectors/#whitespace
+ whitespace = "[\\x20\\t\\r\\n\\f]",
+
+ // http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
+ identifier = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",
+
+ // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors
+ attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace +
+ // Operator (capture 2)
+ "*([*^$|!~]?=)" + whitespace +
+ // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]"
+ "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace +
+ "*\\]",
+
+ pseudos = ":(" + identifier + ")(?:\\((" +
+ // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments:
+ // 1. quoted (capture 3; capture 4 or capture 5)
+ "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" +
+ // 2. simple (capture 6)
+ "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" +
+ // 3. anything else (capture 2)
+ ".*" +
+ ")\\)|)",
+
+ // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter
+ rwhitespace = new RegExp( whitespace + "+", "g" ),
+ rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ),
+
+ rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ),
+ rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ),
+
+ rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ),
+
+ rpseudo = new RegExp( pseudos ),
+ ridentifier = new RegExp( "^" + identifier + "$" ),
+
+ matchExpr = {
+ "ID": new RegExp( "^#(" + identifier + ")" ),
+ "CLASS": new RegExp( "^\\.(" + identifier + ")" ),
+ "TAG": new RegExp( "^(" + identifier + "|[*])" ),
+ "ATTR": new RegExp( "^" + attributes ),
+ "PSEUDO": new RegExp( "^" + pseudos ),
+ "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace +
+ "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace +
+ "*(\\d+)|))" + whitespace + "*\\)|)", "i" ),
+ "bool": new RegExp( "^(?:" + booleans + ")$", "i" ),
+ // For use in libraries implementing .is()
+ // We use this for POS matching in `select`
+ "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" +
+ whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" )
+ },
+
+ rinputs = /^(?:input|select|textarea|button)$/i,
+ rheader = /^h\d$/i,
+
+ rnative = /^[^{]+\{\s*\[native \w/,
+
+ // Easily-parseable/retrievable ID or TAG or CLASS selectors
+ rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,
+
+ rsibling = /[+~]/,
+ rescape = /'|\\/g,
+
+ // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters
+ runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ),
+ funescape = function( _, escaped, escapedWhitespace ) {
+ var high = "0x" + escaped - 0x10000;
+ // NaN means non-codepoint
+ // Support: Firefox<24
+ // Workaround erroneous numeric interpretation of +"0x"
+ return high !== high || escapedWhitespace ?
+ escaped :
+ high < 0 ?
+ // BMP codepoint
+ String.fromCharCode( high + 0x10000 ) :
+ // Supplemental Plane codepoint (surrogate pair)
+ String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 );
+ },
+
+ // Used for iframes
+ // See setDocument()
+ // Removing the function wrapper causes a "Permission Denied"
+ // error in IE
+ unloadHandler = function() {
+ setDocument();
+ };
+
+// Optimize for push.apply( _, NodeList )
+try {
+ push.apply(
+ (arr = slice.call( preferredDoc.childNodes )),
+ preferredDoc.childNodes
+ );
+ // Support: Android<4.0
+ // Detect silently failing push.apply
+ arr[ preferredDoc.childNodes.length ].nodeType;
+} catch ( e ) {
+ push = { apply: arr.length ?
+
+ // Leverage slice if possible
+ function( target, els ) {
+ push_native.apply( target, slice.call(els) );
+ } :
+
+ // Support: IE<9
+ // Otherwise append directly
+ function( target, els ) {
+ var j = target.length,
+ i = 0;
+ // Can't trust NodeList.length
+ while ( (target[j++] = els[i++]) ) {}
+ target.length = j - 1;
+ }
+ };
+}
+
+function Sizzle( selector, context, results, seed ) {
+ var m, i, elem, nid, nidselect, match, groups, newSelector,
+ newContext = context && context.ownerDocument,
+
+ // nodeType defaults to 9, since context defaults to document
+ nodeType = context ? context.nodeType : 9;
+
+ results = results || [];
+
+ // Return early from calls with invalid selector or context
+ if ( typeof selector !== "string" || !selector ||
+ nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) {
+
+ return results;
+ }
+
+ // Try to shortcut find operations (as opposed to filters) in HTML documents
+ if ( !seed ) {
+
+ if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) {
+ setDocument( context );
+ }
+ context = context || document;
+
+ if ( documentIsHTML ) {
+
+ // If the selector is sufficiently simple, try using a "get*By*" DOM method
+ // (excepting DocumentFragment context, where the methods don't exist)
+ if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) {
+
+ // ID selector
+ if ( (m = match[1]) ) {
+
+ // Document context
+ if ( nodeType === 9 ) {
+ if ( (elem = context.getElementById( m )) ) {
+
+ // Support: IE, Opera, Webkit
+ // TODO: identify versions
+ // getElementById can match elements by name instead of ID
+ if ( elem.id === m ) {
+ results.push( elem );
+ return results;
+ }
+ } else {
+ return results;
+ }
+
+ // Element context
+ } else {
+
+ // Support: IE, Opera, Webkit
+ // TODO: identify versions
+ // getElementById can match elements by name instead of ID
+ if ( newContext && (elem = newContext.getElementById( m )) &&
+ contains( context, elem ) &&
+ elem.id === m ) {
+
+ results.push( elem );
+ return results;
+ }
+ }
+
+ // Type selector
+ } else if ( match[2] ) {
+ push.apply( results, context.getElementsByTagName( selector ) );
+ return results;
+
+ // Class selector
+ } else if ( (m = match[3]) && support.getElementsByClassName &&
+ context.getElementsByClassName ) {
+
+ push.apply( results, context.getElementsByClassName( m ) );
+ return results;
+ }
+ }
+
+ // Take advantage of querySelectorAll
+ if ( support.qsa &&
+ !compilerCache[ selector + " " ] &&
+ (!rbuggyQSA || !rbuggyQSA.test( selector )) ) {
+
+ if ( nodeType !== 1 ) {
+ newContext = context;
+ newSelector = selector;
+
+ // qSA looks outside Element context, which is not what we want
+ // Thanks to Andrew Dupont for this workaround technique
+ // Support: IE <=8
+ // Exclude object elements
+ } else if ( context.nodeName.toLowerCase() !== "object" ) {
+
+ // Capture the context ID, setting it first if necessary
+ if ( (nid = context.getAttribute( "id" )) ) {
+ nid = nid.replace( rescape, "\\$&" );
+ } else {
+ context.setAttribute( "id", (nid = expando) );
+ }
+
+ // Prefix every selector in the list
+ groups = tokenize( selector );
+ i = groups.length;
+ nidselect = ridentifier.test( nid ) ? "#" + nid : "[id='" + nid + "']";
+ while ( i-- ) {
+ groups[i] = nidselect + " " + toSelector( groups[i] );
+ }
+ newSelector = groups.join( "," );
+
+ // Expand context for sibling selectors
+ newContext = rsibling.test( selector ) && testContext( context.parentNode ) ||
+ context;
+ }
+
+ if ( newSelector ) {
+ try {
+ push.apply( results,
+ newContext.querySelectorAll( newSelector )
+ );
+ return results;
+ } catch ( qsaError ) {
+ } finally {
+ if ( nid === expando ) {
+ context.removeAttribute( "id" );
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // All others
+ return select( selector.replace( rtrim, "$1" ), context, results, seed );
+}
+
+/**
+ * Create key-value caches of limited size
+ * @returns {function(string, object)} Returns the Object data after storing it on itself with
+ * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength)
+ * isDeleting the oldest entry
+ */
+function createCache() {
+ var keys = [];
+
+ function cache( key, value ) {
+ // Use (key + " ") to avoid collision with native prototype properties (see Issue #157)
+ if ( keys.push( key + " " ) > Expr.cacheLength ) {
+ // Only keep the most recent entries
+ delete cache[ keys.shift() ];
+ }
+ return (cache[ key + " " ] = value);
+ }
+ return cache;
+}
+
+/**
+ * Mark a function for special use by Sizzle
+ * @param {Function} fn The function to mark
+ */
+function markFunction( fn ) {
+ fn[ expando ] = true;
+ return fn;
+}
+
+/**
+ * Support testing using an element
+ * @param {Function} fn Passed the created div and expects a boolean result
+ */
+function assert( fn ) {
+ var div = document.createElement("div");
+
+ try {
+ return !!fn( div );
+ } catch (e) {
+ return false;
+ } finally {
+ // Remove from its parent by default
+ if ( div.parentNode ) {
+ div.parentNode.removeChild( div );
+ }
+ // release memory in IE
+ div = null;
+ }
+}
+
+/**
+ * Adds the same handler for all of the specified attrs
+ * @param {String} attrs Pipe-separated list of attributes
+ * @param {Function} handler The method that will be applied
+ */
+function addHandle( attrs, handler ) {
+ var arr = attrs.split("|"),
+ i = arr.length;
+
+ while ( i-- ) {
+ Expr.attrHandle[ arr[i] ] = handler;
+ }
+}
+
+/**
+ * Checks document order of two siblings
+ * @param {Element} a
+ * @param {Element} b
+ * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b
+ */
+function siblingCheck( a, b ) {
+ var cur = b && a,
+ diff = cur && a.nodeType === 1 && b.nodeType === 1 &&
+ ( ~b.sourceIndex || MAX_NEGATIVE ) -
+ ( ~a.sourceIndex || MAX_NEGATIVE );
+
+ // Use IE sourceIndex if available on both nodes
+ if ( diff ) {
+ return diff;
+ }
+
+ // Check if b follows a
+ if ( cur ) {
+ while ( (cur = cur.nextSibling) ) {
+ if ( cur === b ) {
+ return -1;
+ }
+ }
+ }
+
+ return a ? 1 : -1;
+}
+
+/**
+ * Returns a function to use in pseudos for input types
+ * @param {String} type
+ */
+function createInputPseudo( type ) {
+ return function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return name === "input" && elem.type === type;
+ };
+}
+
+/**
+ * Returns a function to use in pseudos for buttons
+ * @param {String} type
+ */
+function createButtonPseudo( type ) {
+ return function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return (name === "input" || name === "button") && elem.type === type;
+ };
+}
+
+/**
+ * Returns a function to use in pseudos for positionals
+ * @param {Function} fn
+ */
+function createPositionalPseudo( fn ) {
+ return markFunction(function( argument ) {
+ argument = +argument;
+ return markFunction(function( seed, matches ) {
+ var j,
+ matchIndexes = fn( [], seed.length, argument ),
+ i = matchIndexes.length;
+
+ // Match elements found at the specified indexes
+ while ( i-- ) {
+ if ( seed[ (j = matchIndexes[i]) ] ) {
+ seed[j] = !(matches[j] = seed[j]);
+ }
+ }
+ });
+ });
+}
+
+/**
+ * Checks a node for validity as a Sizzle context
+ * @param {Element|Object=} context
+ * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value
+ */
+function testContext( context ) {
+ return context && typeof context.getElementsByTagName !== "undefined" && context;
+}
+
+// Expose support vars for convenience
+support = Sizzle.support = {};
+
+/**
+ * Detects XML nodes
+ * @param {Element|Object} elem An element or a document
+ * @returns {Boolean} True iff elem is a non-HTML XML node
+ */
+isXML = Sizzle.isXML = function( elem ) {
+ // documentElement is verified for cases where it doesn't yet exist
+ // (such as loading iframes in IE - #4833)
+ var documentElement = elem && (elem.ownerDocument || elem).documentElement;
+ return documentElement ? documentElement.nodeName !== "HTML" : false;
+};
+
+/**
+ * Sets document-related variables once based on the current document
+ * @param {Element|Object} [doc] An element or document object to use to set the document
+ * @returns {Object} Returns the current document
+ */
+setDocument = Sizzle.setDocument = function( node ) {
+ var hasCompare, parent,
+ doc = node ? node.ownerDocument || node : preferredDoc;
+
+ // Return early if doc is invalid or already selected
+ if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) {
+ return document;
+ }
+
+ // Update global variables
+ document = doc;
+ docElem = document.documentElement;
+ documentIsHTML = !isXML( document );
+
+ // Support: IE 9-11, Edge
+ // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936)
+ if ( (parent = document.defaultView) && parent.top !== parent ) {
+ // Support: IE 11
+ if ( parent.addEventListener ) {
+ parent.addEventListener( "unload", unloadHandler, false );
+
+ // Support: IE 9 - 10 only
+ } else if ( parent.attachEvent ) {
+ parent.attachEvent( "onunload", unloadHandler );
+ }
+ }
+
+ /* Attributes
+ ---------------------------------------------------------------------- */
+
+ // Support: IE<8
+ // Verify that getAttribute really returns attributes and not properties
+ // (excepting IE8 booleans)
+ support.attributes = assert(function( div ) {
+ div.className = "i";
+ return !div.getAttribute("className");
+ });
+
+ /* getElement(s)By*
+ ---------------------------------------------------------------------- */
+
+ // Check if getElementsByTagName("*") returns only elements
+ support.getElementsByTagName = assert(function( div ) {
+ div.appendChild( document.createComment("") );
+ return !div.getElementsByTagName("*").length;
+ });
+
+ // Support: IE<9
+ support.getElementsByClassName = rnative.test( document.getElementsByClassName );
+
+ // Support: IE<10
+ // Check if getElementById returns elements by name
+ // The broken getElementById methods don't pick up programatically-set names,
+ // so use a roundabout getElementsByName test
+ support.getById = assert(function( div ) {
+ docElem.appendChild( div ).id = expando;
+ return !document.getElementsByName || !document.getElementsByName( expando ).length;
+ });
+
+ // ID find and filter
+ if ( support.getById ) {
+ Expr.find["ID"] = function( id, context ) {
+ if ( typeof context.getElementById !== "undefined" && documentIsHTML ) {
+ var m = context.getElementById( id );
+ return m ? [ m ] : [];
+ }
+ };
+ Expr.filter["ID"] = function( id ) {
+ var attrId = id.replace( runescape, funescape );
+ return function( elem ) {
+ return elem.getAttribute("id") === attrId;
+ };
+ };
+ } else {
+ // Support: IE6/7
+ // getElementById is not reliable as a find shortcut
+ delete Expr.find["ID"];
+
+ Expr.filter["ID"] = function( id ) {
+ var attrId = id.replace( runescape, funescape );
+ return function( elem ) {
+ var node = typeof elem.getAttributeNode !== "undefined" &&
+ elem.getAttributeNode("id");
+ return node && node.value === attrId;
+ };
+ };
+ }
+
+ // Tag
+ Expr.find["TAG"] = support.getElementsByTagName ?
+ function( tag, context ) {
+ if ( typeof context.getElementsByTagName !== "undefined" ) {
+ return context.getElementsByTagName( tag );
+
+ // DocumentFragment nodes don't have gEBTN
+ } else if ( support.qsa ) {
+ return context.querySelectorAll( tag );
+ }
+ } :
+
+ function( tag, context ) {
+ var elem,
+ tmp = [],
+ i = 0,
+ // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too
+ results = context.getElementsByTagName( tag );
+
+ // Filter out possible comments
+ if ( tag === "*" ) {
+ while ( (elem = results[i++]) ) {
+ if ( elem.nodeType === 1 ) {
+ tmp.push( elem );
+ }
+ }
+
+ return tmp;
+ }
+ return results;
+ };
+
+ // Class
+ Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) {
+ if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) {
+ return context.getElementsByClassName( className );
+ }
+ };
+
+ /* QSA/matchesSelector
+ ---------------------------------------------------------------------- */
+
+ // QSA and matchesSelector support
+
+ // matchesSelector(:active) reports false when true (IE9/Opera 11.5)
+ rbuggyMatches = [];
+
+ // qSa(:focus) reports false when true (Chrome 21)
+ // We allow this because of a bug in IE8/9 that throws an error
+ // whenever `document.activeElement` is accessed on an iframe
+ // So, we allow :focus to pass through QSA all the time to avoid the IE error
+ // See http://bugs.jquery.com/ticket/13378
+ rbuggyQSA = [];
+
+ if ( (support.qsa = rnative.test( document.querySelectorAll )) ) {
+ // Build QSA regex
+ // Regex strategy adopted from Diego Perini
+ assert(function( div ) {
+ // Select is set to empty string on purpose
+ // This is to test IE's treatment of not explicitly
+ // setting a boolean content attribute,
+ // since its presence should be enough
+ // http://bugs.jquery.com/ticket/12359
+ docElem.appendChild( div ).innerHTML = " " +
+ "" +
+ " ";
+
+ // Support: IE8, Opera 11-12.16
+ // Nothing should be selected when empty strings follow ^= or $= or *=
+ // The test attribute must be unknown in Opera but "safe" for WinRT
+ // http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section
+ if ( div.querySelectorAll("[msallowcapture^='']").length ) {
+ rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" );
+ }
+
+ // Support: IE8
+ // Boolean attributes and "value" are not treated correctly
+ if ( !div.querySelectorAll("[selected]").length ) {
+ rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" );
+ }
+
+ // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+
+ if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) {
+ rbuggyQSA.push("~=");
+ }
+
+ // Webkit/Opera - :checked should return selected option elements
+ // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
+ // IE8 throws error here and will not see later tests
+ if ( !div.querySelectorAll(":checked").length ) {
+ rbuggyQSA.push(":checked");
+ }
+
+ // Support: Safari 8+, iOS 8+
+ // https://bugs.webkit.org/show_bug.cgi?id=136851
+ // In-page `selector#id sibing-combinator selector` fails
+ if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) {
+ rbuggyQSA.push(".#.+[+~]");
+ }
+ });
+
+ assert(function( div ) {
+ // Support: Windows 8 Native Apps
+ // The type and name attributes are restricted during .innerHTML assignment
+ var input = document.createElement("input");
+ input.setAttribute( "type", "hidden" );
+ div.appendChild( input ).setAttribute( "name", "D" );
+
+ // Support: IE8
+ // Enforce case-sensitivity of name attribute
+ if ( div.querySelectorAll("[name=d]").length ) {
+ rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" );
+ }
+
+ // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled)
+ // IE8 throws error here and will not see later tests
+ if ( !div.querySelectorAll(":enabled").length ) {
+ rbuggyQSA.push( ":enabled", ":disabled" );
+ }
+
+ // Opera 10-11 does not throw on post-comma invalid pseudos
+ div.querySelectorAll("*,:x");
+ rbuggyQSA.push(",.*:");
+ });
+ }
+
+ if ( (support.matchesSelector = rnative.test( (matches = docElem.matches ||
+ docElem.webkitMatchesSelector ||
+ docElem.mozMatchesSelector ||
+ docElem.oMatchesSelector ||
+ docElem.msMatchesSelector) )) ) {
+
+ assert(function( div ) {
+ // Check to see if it's possible to do matchesSelector
+ // on a disconnected node (IE 9)
+ support.disconnectedMatch = matches.call( div, "div" );
+
+ // This should fail with an exception
+ // Gecko does not error, returns false instead
+ matches.call( div, "[s!='']:x" );
+ rbuggyMatches.push( "!=", pseudos );
+ });
+ }
+
+ rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") );
+ rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") );
+
+ /* Contains
+ ---------------------------------------------------------------------- */
+ hasCompare = rnative.test( docElem.compareDocumentPosition );
+
+ // Element contains another
+ // Purposefully self-exclusive
+ // As in, an element does not contain itself
+ contains = hasCompare || rnative.test( docElem.contains ) ?
+ function( a, b ) {
+ var adown = a.nodeType === 9 ? a.documentElement : a,
+ bup = b && b.parentNode;
+ return a === bup || !!( bup && bup.nodeType === 1 && (
+ adown.contains ?
+ adown.contains( bup ) :
+ a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16
+ ));
+ } :
+ function( a, b ) {
+ if ( b ) {
+ while ( (b = b.parentNode) ) {
+ if ( b === a ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+
+ /* Sorting
+ ---------------------------------------------------------------------- */
+
+ // Document order sorting
+ sortOrder = hasCompare ?
+ function( a, b ) {
+
+ // Flag for duplicate removal
+ if ( a === b ) {
+ hasDuplicate = true;
+ return 0;
+ }
+
+ // Sort on method existence if only one input has compareDocumentPosition
+ var compare = !a.compareDocumentPosition - !b.compareDocumentPosition;
+ if ( compare ) {
+ return compare;
+ }
+
+ // Calculate position if both inputs belong to the same document
+ compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ?
+ a.compareDocumentPosition( b ) :
+
+ // Otherwise we know they are disconnected
+ 1;
+
+ // Disconnected nodes
+ if ( compare & 1 ||
+ (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) {
+
+ // Choose the first element that is related to our preferred document
+ if ( a === document || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) {
+ return -1;
+ }
+ if ( b === document || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) {
+ return 1;
+ }
+
+ // Maintain original order
+ return sortInput ?
+ ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
+ 0;
+ }
+
+ return compare & 4 ? -1 : 1;
+ } :
+ function( a, b ) {
+ // Exit early if the nodes are identical
+ if ( a === b ) {
+ hasDuplicate = true;
+ return 0;
+ }
+
+ var cur,
+ i = 0,
+ aup = a.parentNode,
+ bup = b.parentNode,
+ ap = [ a ],
+ bp = [ b ];
+
+ // Parentless nodes are either documents or disconnected
+ if ( !aup || !bup ) {
+ return a === document ? -1 :
+ b === document ? 1 :
+ aup ? -1 :
+ bup ? 1 :
+ sortInput ?
+ ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
+ 0;
+
+ // If the nodes are siblings, we can do a quick check
+ } else if ( aup === bup ) {
+ return siblingCheck( a, b );
+ }
+
+ // Otherwise we need full lists of their ancestors for comparison
+ cur = a;
+ while ( (cur = cur.parentNode) ) {
+ ap.unshift( cur );
+ }
+ cur = b;
+ while ( (cur = cur.parentNode) ) {
+ bp.unshift( cur );
+ }
+
+ // Walk down the tree looking for a discrepancy
+ while ( ap[i] === bp[i] ) {
+ i++;
+ }
+
+ return i ?
+ // Do a sibling check if the nodes have a common ancestor
+ siblingCheck( ap[i], bp[i] ) :
+
+ // Otherwise nodes in our document sort first
+ ap[i] === preferredDoc ? -1 :
+ bp[i] === preferredDoc ? 1 :
+ 0;
+ };
+
+ return document;
+};
+
+Sizzle.matches = function( expr, elements ) {
+ return Sizzle( expr, null, null, elements );
+};
+
+Sizzle.matchesSelector = function( elem, expr ) {
+ // Set document vars if needed
+ if ( ( elem.ownerDocument || elem ) !== document ) {
+ setDocument( elem );
+ }
+
+ // Make sure that attribute selectors are quoted
+ expr = expr.replace( rattributeQuotes, "='$1']" );
+
+ if ( support.matchesSelector && documentIsHTML &&
+ !compilerCache[ expr + " " ] &&
+ ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) &&
+ ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) {
+
+ try {
+ var ret = matches.call( elem, expr );
+
+ // IE 9's matchesSelector returns false on disconnected nodes
+ if ( ret || support.disconnectedMatch ||
+ // As well, disconnected nodes are said to be in a document
+ // fragment in IE 9
+ elem.document && elem.document.nodeType !== 11 ) {
+ return ret;
+ }
+ } catch (e) {}
+ }
+
+ return Sizzle( expr, document, null, [ elem ] ).length > 0;
+};
+
+Sizzle.contains = function( context, elem ) {
+ // Set document vars if needed
+ if ( ( context.ownerDocument || context ) !== document ) {
+ setDocument( context );
+ }
+ return contains( context, elem );
+};
+
+Sizzle.attr = function( elem, name ) {
+ // Set document vars if needed
+ if ( ( elem.ownerDocument || elem ) !== document ) {
+ setDocument( elem );
+ }
+
+ var fn = Expr.attrHandle[ name.toLowerCase() ],
+ // Don't get fooled by Object.prototype properties (jQuery #13807)
+ val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ?
+ fn( elem, name, !documentIsHTML ) :
+ undefined;
+
+ return val !== undefined ?
+ val :
+ support.attributes || !documentIsHTML ?
+ elem.getAttribute( name ) :
+ (val = elem.getAttributeNode(name)) && val.specified ?
+ val.value :
+ null;
+};
+
+Sizzle.error = function( msg ) {
+ throw new Error( "Syntax error, unrecognized expression: " + msg );
+};
+
+/**
+ * Document sorting and removing duplicates
+ * @param {ArrayLike} results
+ */
+Sizzle.uniqueSort = function( results ) {
+ var elem,
+ duplicates = [],
+ j = 0,
+ i = 0;
+
+ // Unless we *know* we can detect duplicates, assume their presence
+ hasDuplicate = !support.detectDuplicates;
+ sortInput = !support.sortStable && results.slice( 0 );
+ results.sort( sortOrder );
+
+ if ( hasDuplicate ) {
+ while ( (elem = results[i++]) ) {
+ if ( elem === results[ i ] ) {
+ j = duplicates.push( i );
+ }
+ }
+ while ( j-- ) {
+ results.splice( duplicates[ j ], 1 );
+ }
+ }
+
+ // Clear input after sorting to release objects
+ // See https://github.com/jquery/sizzle/pull/225
+ sortInput = null;
+
+ return results;
+};
+
+/**
+ * Utility function for retrieving the text value of an array of DOM nodes
+ * @param {Array|Element} elem
+ */
+getText = Sizzle.getText = function( elem ) {
+ var node,
+ ret = "",
+ i = 0,
+ nodeType = elem.nodeType;
+
+ if ( !nodeType ) {
+ // If no nodeType, this is expected to be an array
+ while ( (node = elem[i++]) ) {
+ // Do not traverse comment nodes
+ ret += getText( node );
+ }
+ } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) {
+ // Use textContent for elements
+ // innerText usage removed for consistency of new lines (jQuery #11153)
+ if ( typeof elem.textContent === "string" ) {
+ return elem.textContent;
+ } else {
+ // Traverse its children
+ for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
+ ret += getText( elem );
+ }
+ }
+ } else if ( nodeType === 3 || nodeType === 4 ) {
+ return elem.nodeValue;
+ }
+ // Do not include comment or processing instruction nodes
+
+ return ret;
+};
+
+Expr = Sizzle.selectors = {
+
+ // Can be adjusted by the user
+ cacheLength: 50,
+
+ createPseudo: markFunction,
+
+ match: matchExpr,
+
+ attrHandle: {},
+
+ find: {},
+
+ relative: {
+ ">": { dir: "parentNode", first: true },
+ " ": { dir: "parentNode" },
+ "+": { dir: "previousSibling", first: true },
+ "~": { dir: "previousSibling" }
+ },
+
+ preFilter: {
+ "ATTR": function( match ) {
+ match[1] = match[1].replace( runescape, funescape );
+
+ // Move the given value to match[3] whether quoted or unquoted
+ match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape );
+
+ if ( match[2] === "~=" ) {
+ match[3] = " " + match[3] + " ";
+ }
+
+ return match.slice( 0, 4 );
+ },
+
+ "CHILD": function( match ) {
+ /* matches from matchExpr["CHILD"]
+ 1 type (only|nth|...)
+ 2 what (child|of-type)
+ 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...)
+ 4 xn-component of xn+y argument ([+-]?\d*n|)
+ 5 sign of xn-component
+ 6 x of xn-component
+ 7 sign of y-component
+ 8 y of y-component
+ */
+ match[1] = match[1].toLowerCase();
+
+ if ( match[1].slice( 0, 3 ) === "nth" ) {
+ // nth-* requires argument
+ if ( !match[3] ) {
+ Sizzle.error( match[0] );
+ }
+
+ // numeric x and y parameters for Expr.filter.CHILD
+ // remember that false/true cast respectively to 0/1
+ match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) );
+ match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" );
+
+ // other types prohibit arguments
+ } else if ( match[3] ) {
+ Sizzle.error( match[0] );
+ }
+
+ return match;
+ },
+
+ "PSEUDO": function( match ) {
+ var excess,
+ unquoted = !match[6] && match[2];
+
+ if ( matchExpr["CHILD"].test( match[0] ) ) {
+ return null;
+ }
+
+ // Accept quoted arguments as-is
+ if ( match[3] ) {
+ match[2] = match[4] || match[5] || "";
+
+ // Strip excess characters from unquoted arguments
+ } else if ( unquoted && rpseudo.test( unquoted ) &&
+ // Get excess from tokenize (recursively)
+ (excess = tokenize( unquoted, true )) &&
+ // advance to the next closing parenthesis
+ (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) {
+
+ // excess is a negative index
+ match[0] = match[0].slice( 0, excess );
+ match[2] = unquoted.slice( 0, excess );
+ }
+
+ // Return only captures needed by the pseudo filter method (type and argument)
+ return match.slice( 0, 3 );
+ }
+ },
+
+ filter: {
+
+ "TAG": function( nodeNameSelector ) {
+ var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase();
+ return nodeNameSelector === "*" ?
+ function() { return true; } :
+ function( elem ) {
+ return elem.nodeName && elem.nodeName.toLowerCase() === nodeName;
+ };
+ },
+
+ "CLASS": function( className ) {
+ var pattern = classCache[ className + " " ];
+
+ return pattern ||
+ (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) &&
+ classCache( className, function( elem ) {
+ return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" );
+ });
+ },
+
+ "ATTR": function( name, operator, check ) {
+ return function( elem ) {
+ var result = Sizzle.attr( elem, name );
+
+ if ( result == null ) {
+ return operator === "!=";
+ }
+ if ( !operator ) {
+ return true;
+ }
+
+ result += "";
+
+ return operator === "=" ? result === check :
+ operator === "!=" ? result !== check :
+ operator === "^=" ? check && result.indexOf( check ) === 0 :
+ operator === "*=" ? check && result.indexOf( check ) > -1 :
+ operator === "$=" ? check && result.slice( -check.length ) === check :
+ operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 :
+ operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" :
+ false;
+ };
+ },
+
+ "CHILD": function( type, what, argument, first, last ) {
+ var simple = type.slice( 0, 3 ) !== "nth",
+ forward = type.slice( -4 ) !== "last",
+ ofType = what === "of-type";
+
+ return first === 1 && last === 0 ?
+
+ // Shortcut for :nth-*(n)
+ function( elem ) {
+ return !!elem.parentNode;
+ } :
+
+ function( elem, context, xml ) {
+ var cache, uniqueCache, outerCache, node, nodeIndex, start,
+ dir = simple !== forward ? "nextSibling" : "previousSibling",
+ parent = elem.parentNode,
+ name = ofType && elem.nodeName.toLowerCase(),
+ useCache = !xml && !ofType,
+ diff = false;
+
+ if ( parent ) {
+
+ // :(first|last|only)-(child|of-type)
+ if ( simple ) {
+ while ( dir ) {
+ node = elem;
+ while ( (node = node[ dir ]) ) {
+ if ( ofType ?
+ node.nodeName.toLowerCase() === name :
+ node.nodeType === 1 ) {
+
+ return false;
+ }
+ }
+ // Reverse direction for :only-* (if we haven't yet done so)
+ start = dir = type === "only" && !start && "nextSibling";
+ }
+ return true;
+ }
+
+ start = [ forward ? parent.firstChild : parent.lastChild ];
+
+ // non-xml :nth-child(...) stores cache data on `parent`
+ if ( forward && useCache ) {
+
+ // Seek `elem` from a previously-cached index
+
+ // ...in a gzip-friendly way
+ node = parent;
+ outerCache = node[ expando ] || (node[ expando ] = {});
+
+ // Support: IE <9 only
+ // Defend against cloned attroperties (jQuery gh-1709)
+ uniqueCache = outerCache[ node.uniqueID ] ||
+ (outerCache[ node.uniqueID ] = {});
+
+ cache = uniqueCache[ type ] || [];
+ nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ];
+ diff = nodeIndex && cache[ 2 ];
+ node = nodeIndex && parent.childNodes[ nodeIndex ];
+
+ while ( (node = ++nodeIndex && node && node[ dir ] ||
+
+ // Fallback to seeking `elem` from the start
+ (diff = nodeIndex = 0) || start.pop()) ) {
+
+ // When found, cache indexes on `parent` and break
+ if ( node.nodeType === 1 && ++diff && node === elem ) {
+ uniqueCache[ type ] = [ dirruns, nodeIndex, diff ];
+ break;
+ }
+ }
+
+ } else {
+ // Use previously-cached element index if available
+ if ( useCache ) {
+ // ...in a gzip-friendly way
+ node = elem;
+ outerCache = node[ expando ] || (node[ expando ] = {});
+
+ // Support: IE <9 only
+ // Defend against cloned attroperties (jQuery gh-1709)
+ uniqueCache = outerCache[ node.uniqueID ] ||
+ (outerCache[ node.uniqueID ] = {});
+
+ cache = uniqueCache[ type ] || [];
+ nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ];
+ diff = nodeIndex;
+ }
+
+ // xml :nth-child(...)
+ // or :nth-last-child(...) or :nth(-last)?-of-type(...)
+ if ( diff === false ) {
+ // Use the same loop as above to seek `elem` from the start
+ while ( (node = ++nodeIndex && node && node[ dir ] ||
+ (diff = nodeIndex = 0) || start.pop()) ) {
+
+ if ( ( ofType ?
+ node.nodeName.toLowerCase() === name :
+ node.nodeType === 1 ) &&
+ ++diff ) {
+
+ // Cache the index of each encountered element
+ if ( useCache ) {
+ outerCache = node[ expando ] || (node[ expando ] = {});
+
+ // Support: IE <9 only
+ // Defend against cloned attroperties (jQuery gh-1709)
+ uniqueCache = outerCache[ node.uniqueID ] ||
+ (outerCache[ node.uniqueID ] = {});
+
+ uniqueCache[ type ] = [ dirruns, diff ];
+ }
+
+ if ( node === elem ) {
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Incorporate the offset, then check against cycle size
+ diff -= last;
+ return diff === first || ( diff % first === 0 && diff / first >= 0 );
+ }
+ };
+ },
+
+ "PSEUDO": function( pseudo, argument ) {
+ // pseudo-class names are case-insensitive
+ // http://www.w3.org/TR/selectors/#pseudo-classes
+ // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters
+ // Remember that setFilters inherits from pseudos
+ var args,
+ fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] ||
+ Sizzle.error( "unsupported pseudo: " + pseudo );
+
+ // The user may use createPseudo to indicate that
+ // arguments are needed to create the filter function
+ // just as Sizzle does
+ if ( fn[ expando ] ) {
+ return fn( argument );
+ }
+
+ // But maintain support for old signatures
+ if ( fn.length > 1 ) {
+ args = [ pseudo, pseudo, "", argument ];
+ return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ?
+ markFunction(function( seed, matches ) {
+ var idx,
+ matched = fn( seed, argument ),
+ i = matched.length;
+ while ( i-- ) {
+ idx = indexOf( seed, matched[i] );
+ seed[ idx ] = !( matches[ idx ] = matched[i] );
+ }
+ }) :
+ function( elem ) {
+ return fn( elem, 0, args );
+ };
+ }
+
+ return fn;
+ }
+ },
+
+ pseudos: {
+ // Potentially complex pseudos
+ "not": markFunction(function( selector ) {
+ // Trim the selector passed to compile
+ // to avoid treating leading and trailing
+ // spaces as combinators
+ var input = [],
+ results = [],
+ matcher = compile( selector.replace( rtrim, "$1" ) );
+
+ return matcher[ expando ] ?
+ markFunction(function( seed, matches, context, xml ) {
+ var elem,
+ unmatched = matcher( seed, null, xml, [] ),
+ i = seed.length;
+
+ // Match elements unmatched by `matcher`
+ while ( i-- ) {
+ if ( (elem = unmatched[i]) ) {
+ seed[i] = !(matches[i] = elem);
+ }
+ }
+ }) :
+ function( elem, context, xml ) {
+ input[0] = elem;
+ matcher( input, null, xml, results );
+ // Don't keep the element (issue #299)
+ input[0] = null;
+ return !results.pop();
+ };
+ }),
+
+ "has": markFunction(function( selector ) {
+ return function( elem ) {
+ return Sizzle( selector, elem ).length > 0;
+ };
+ }),
+
+ "contains": markFunction(function( text ) {
+ text = text.replace( runescape, funescape );
+ return function( elem ) {
+ return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1;
+ };
+ }),
+
+ // "Whether an element is represented by a :lang() selector
+ // is based solely on the element's language value
+ // being equal to the identifier C,
+ // or beginning with the identifier C immediately followed by "-".
+ // The matching of C against the element's language value is performed case-insensitively.
+ // The identifier C does not have to be a valid language name."
+ // http://www.w3.org/TR/selectors/#lang-pseudo
+ "lang": markFunction( function( lang ) {
+ // lang value must be a valid identifier
+ if ( !ridentifier.test(lang || "") ) {
+ Sizzle.error( "unsupported lang: " + lang );
+ }
+ lang = lang.replace( runescape, funescape ).toLowerCase();
+ return function( elem ) {
+ var elemLang;
+ do {
+ if ( (elemLang = documentIsHTML ?
+ elem.lang :
+ elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) {
+
+ elemLang = elemLang.toLowerCase();
+ return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0;
+ }
+ } while ( (elem = elem.parentNode) && elem.nodeType === 1 );
+ return false;
+ };
+ }),
+
+ // Miscellaneous
+ "target": function( elem ) {
+ var hash = window.location && window.location.hash;
+ return hash && hash.slice( 1 ) === elem.id;
+ },
+
+ "root": function( elem ) {
+ return elem === docElem;
+ },
+
+ "focus": function( elem ) {
+ return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex);
+ },
+
+ // Boolean properties
+ "enabled": function( elem ) {
+ return elem.disabled === false;
+ },
+
+ "disabled": function( elem ) {
+ return elem.disabled === true;
+ },
+
+ "checked": function( elem ) {
+ // In CSS3, :checked should return both checked and selected elements
+ // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
+ var nodeName = elem.nodeName.toLowerCase();
+ return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected);
+ },
+
+ "selected": function( elem ) {
+ // Accessing this property makes selected-by-default
+ // options in Safari work properly
+ if ( elem.parentNode ) {
+ elem.parentNode.selectedIndex;
+ }
+
+ return elem.selected === true;
+ },
+
+ // Contents
+ "empty": function( elem ) {
+ // http://www.w3.org/TR/selectors/#empty-pseudo
+ // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5),
+ // but not by others (comment: 8; processing instruction: 7; etc.)
+ // nodeType < 6 works because attributes (2) do not appear as children
+ for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
+ if ( elem.nodeType < 6 ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ "parent": function( elem ) {
+ return !Expr.pseudos["empty"]( elem );
+ },
+
+ // Element/input types
+ "header": function( elem ) {
+ return rheader.test( elem.nodeName );
+ },
+
+ "input": function( elem ) {
+ return rinputs.test( elem.nodeName );
+ },
+
+ "button": function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return name === "input" && elem.type === "button" || name === "button";
+ },
+
+ "text": function( elem ) {
+ var attr;
+ return elem.nodeName.toLowerCase() === "input" &&
+ elem.type === "text" &&
+
+ // Support: IE<8
+ // New HTML5 attribute values (e.g., "search") appear with elem.type === "text"
+ ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" );
+ },
+
+ // Position-in-collection
+ "first": createPositionalPseudo(function() {
+ return [ 0 ];
+ }),
+
+ "last": createPositionalPseudo(function( matchIndexes, length ) {
+ return [ length - 1 ];
+ }),
+
+ "eq": createPositionalPseudo(function( matchIndexes, length, argument ) {
+ return [ argument < 0 ? argument + length : argument ];
+ }),
+
+ "even": createPositionalPseudo(function( matchIndexes, length ) {
+ var i = 0;
+ for ( ; i < length; i += 2 ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ }),
+
+ "odd": createPositionalPseudo(function( matchIndexes, length ) {
+ var i = 1;
+ for ( ; i < length; i += 2 ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ }),
+
+ "lt": createPositionalPseudo(function( matchIndexes, length, argument ) {
+ var i = argument < 0 ? argument + length : argument;
+ for ( ; --i >= 0; ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ }),
+
+ "gt": createPositionalPseudo(function( matchIndexes, length, argument ) {
+ var i = argument < 0 ? argument + length : argument;
+ for ( ; ++i < length; ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ })
+ }
+};
+
+Expr.pseudos["nth"] = Expr.pseudos["eq"];
+
+// Add button/input type pseudos
+for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) {
+ Expr.pseudos[ i ] = createInputPseudo( i );
+}
+for ( i in { submit: true, reset: true } ) {
+ Expr.pseudos[ i ] = createButtonPseudo( i );
+}
+
+// Easy API for creating new setFilters
+function setFilters() {}
+setFilters.prototype = Expr.filters = Expr.pseudos;
+Expr.setFilters = new setFilters();
+
+tokenize = Sizzle.tokenize = function( selector, parseOnly ) {
+ var matched, match, tokens, type,
+ soFar, groups, preFilters,
+ cached = tokenCache[ selector + " " ];
+
+ if ( cached ) {
+ return parseOnly ? 0 : cached.slice( 0 );
+ }
+
+ soFar = selector;
+ groups = [];
+ preFilters = Expr.preFilter;
+
+ while ( soFar ) {
+
+ // Comma and first run
+ if ( !matched || (match = rcomma.exec( soFar )) ) {
+ if ( match ) {
+ // Don't consume trailing commas as valid
+ soFar = soFar.slice( match[0].length ) || soFar;
+ }
+ groups.push( (tokens = []) );
+ }
+
+ matched = false;
+
+ // Combinators
+ if ( (match = rcombinators.exec( soFar )) ) {
+ matched = match.shift();
+ tokens.push({
+ value: matched,
+ // Cast descendant combinators to space
+ type: match[0].replace( rtrim, " " )
+ });
+ soFar = soFar.slice( matched.length );
+ }
+
+ // Filters
+ for ( type in Expr.filter ) {
+ if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] ||
+ (match = preFilters[ type ]( match ))) ) {
+ matched = match.shift();
+ tokens.push({
+ value: matched,
+ type: type,
+ matches: match
+ });
+ soFar = soFar.slice( matched.length );
+ }
+ }
+
+ if ( !matched ) {
+ break;
+ }
+ }
+
+ // Return the length of the invalid excess
+ // if we're just parsing
+ // Otherwise, throw an error or return tokens
+ return parseOnly ?
+ soFar.length :
+ soFar ?
+ Sizzle.error( selector ) :
+ // Cache the tokens
+ tokenCache( selector, groups ).slice( 0 );
+};
+
+function toSelector( tokens ) {
+ var i = 0,
+ len = tokens.length,
+ selector = "";
+ for ( ; i < len; i++ ) {
+ selector += tokens[i].value;
+ }
+ return selector;
+}
+
+function addCombinator( matcher, combinator, base ) {
+ var dir = combinator.dir,
+ checkNonElements = base && dir === "parentNode",
+ doneName = done++;
+
+ return combinator.first ?
+ // Check against closest ancestor/preceding element
+ function( elem, context, xml ) {
+ while ( (elem = elem[ dir ]) ) {
+ if ( elem.nodeType === 1 || checkNonElements ) {
+ return matcher( elem, context, xml );
+ }
+ }
+ } :
+
+ // Check against all ancestor/preceding elements
+ function( elem, context, xml ) {
+ var oldCache, uniqueCache, outerCache,
+ newCache = [ dirruns, doneName ];
+
+ // We can't set arbitrary data on XML nodes, so they don't benefit from combinator caching
+ if ( xml ) {
+ while ( (elem = elem[ dir ]) ) {
+ if ( elem.nodeType === 1 || checkNonElements ) {
+ if ( matcher( elem, context, xml ) ) {
+ return true;
+ }
+ }
+ }
+ } else {
+ while ( (elem = elem[ dir ]) ) {
+ if ( elem.nodeType === 1 || checkNonElements ) {
+ outerCache = elem[ expando ] || (elem[ expando ] = {});
+
+ // Support: IE <9 only
+ // Defend against cloned attroperties (jQuery gh-1709)
+ uniqueCache = outerCache[ elem.uniqueID ] || (outerCache[ elem.uniqueID ] = {});
+
+ if ( (oldCache = uniqueCache[ dir ]) &&
+ oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) {
+
+ // Assign to newCache so results back-propagate to previous elements
+ return (newCache[ 2 ] = oldCache[ 2 ]);
+ } else {
+ // Reuse newcache so results back-propagate to previous elements
+ uniqueCache[ dir ] = newCache;
+
+ // A match means we're done; a fail means we have to keep checking
+ if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ };
+}
+
+function elementMatcher( matchers ) {
+ return matchers.length > 1 ?
+ function( elem, context, xml ) {
+ var i = matchers.length;
+ while ( i-- ) {
+ if ( !matchers[i]( elem, context, xml ) ) {
+ return false;
+ }
+ }
+ return true;
+ } :
+ matchers[0];
+}
+
+function multipleContexts( selector, contexts, results ) {
+ var i = 0,
+ len = contexts.length;
+ for ( ; i < len; i++ ) {
+ Sizzle( selector, contexts[i], results );
+ }
+ return results;
+}
+
+function condense( unmatched, map, filter, context, xml ) {
+ var elem,
+ newUnmatched = [],
+ i = 0,
+ len = unmatched.length,
+ mapped = map != null;
+
+ for ( ; i < len; i++ ) {
+ if ( (elem = unmatched[i]) ) {
+ if ( !filter || filter( elem, context, xml ) ) {
+ newUnmatched.push( elem );
+ if ( mapped ) {
+ map.push( i );
+ }
+ }
+ }
+ }
+
+ return newUnmatched;
+}
+
+function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) {
+ if ( postFilter && !postFilter[ expando ] ) {
+ postFilter = setMatcher( postFilter );
+ }
+ if ( postFinder && !postFinder[ expando ] ) {
+ postFinder = setMatcher( postFinder, postSelector );
+ }
+ return markFunction(function( seed, results, context, xml ) {
+ var temp, i, elem,
+ preMap = [],
+ postMap = [],
+ preexisting = results.length,
+
+ // Get initial elements from seed or context
+ elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ),
+
+ // Prefilter to get matcher input, preserving a map for seed-results synchronization
+ matcherIn = preFilter && ( seed || !selector ) ?
+ condense( elems, preMap, preFilter, context, xml ) :
+ elems,
+
+ matcherOut = matcher ?
+ // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results,
+ postFinder || ( seed ? preFilter : preexisting || postFilter ) ?
+
+ // ...intermediate processing is necessary
+ [] :
+
+ // ...otherwise use results directly
+ results :
+ matcherIn;
+
+ // Find primary matches
+ if ( matcher ) {
+ matcher( matcherIn, matcherOut, context, xml );
+ }
+
+ // Apply postFilter
+ if ( postFilter ) {
+ temp = condense( matcherOut, postMap );
+ postFilter( temp, [], context, xml );
+
+ // Un-match failing elements by moving them back to matcherIn
+ i = temp.length;
+ while ( i-- ) {
+ if ( (elem = temp[i]) ) {
+ matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem);
+ }
+ }
+ }
+
+ if ( seed ) {
+ if ( postFinder || preFilter ) {
+ if ( postFinder ) {
+ // Get the final matcherOut by condensing this intermediate into postFinder contexts
+ temp = [];
+ i = matcherOut.length;
+ while ( i-- ) {
+ if ( (elem = matcherOut[i]) ) {
+ // Restore matcherIn since elem is not yet a final match
+ temp.push( (matcherIn[i] = elem) );
+ }
+ }
+ postFinder( null, (matcherOut = []), temp, xml );
+ }
+
+ // Move matched elements from seed to results to keep them synchronized
+ i = matcherOut.length;
+ while ( i-- ) {
+ if ( (elem = matcherOut[i]) &&
+ (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) {
+
+ seed[temp] = !(results[temp] = elem);
+ }
+ }
+ }
+
+ // Add elements to results, through postFinder if defined
+ } else {
+ matcherOut = condense(
+ matcherOut === results ?
+ matcherOut.splice( preexisting, matcherOut.length ) :
+ matcherOut
+ );
+ if ( postFinder ) {
+ postFinder( null, results, matcherOut, xml );
+ } else {
+ push.apply( results, matcherOut );
+ }
+ }
+ });
+}
+
+function matcherFromTokens( tokens ) {
+ var checkContext, matcher, j,
+ len = tokens.length,
+ leadingRelative = Expr.relative[ tokens[0].type ],
+ implicitRelative = leadingRelative || Expr.relative[" "],
+ i = leadingRelative ? 1 : 0,
+
+ // The foundational matcher ensures that elements are reachable from top-level context(s)
+ matchContext = addCombinator( function( elem ) {
+ return elem === checkContext;
+ }, implicitRelative, true ),
+ matchAnyContext = addCombinator( function( elem ) {
+ return indexOf( checkContext, elem ) > -1;
+ }, implicitRelative, true ),
+ matchers = [ function( elem, context, xml ) {
+ var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || (
+ (checkContext = context).nodeType ?
+ matchContext( elem, context, xml ) :
+ matchAnyContext( elem, context, xml ) );
+ // Avoid hanging onto element (issue #299)
+ checkContext = null;
+ return ret;
+ } ];
+
+ for ( ; i < len; i++ ) {
+ if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
+ matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
+ } else {
+ matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );
+
+ // Return special upon seeing a positional matcher
+ if ( matcher[ expando ] ) {
+ // Find the next relative operator (if any) for proper handling
+ j = ++i;
+ for ( ; j < len; j++ ) {
+ if ( Expr.relative[ tokens[j].type ] ) {
+ break;
+ }
+ }
+ return setMatcher(
+ i > 1 && elementMatcher( matchers ),
+ i > 1 && toSelector(
+ // If the preceding token was a descendant combinator, insert an implicit any-element `*`
+ tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" })
+ ).replace( rtrim, "$1" ),
+ matcher,
+ i < j && matcherFromTokens( tokens.slice( i, j ) ),
+ j < len && matcherFromTokens( (tokens = tokens.slice( j )) ),
+ j < len && toSelector( tokens )
+ );
+ }
+ matchers.push( matcher );
+ }
+ }
+
+ return elementMatcher( matchers );
+}
+
+function matcherFromGroupMatchers( elementMatchers, setMatchers ) {
+ var bySet = setMatchers.length > 0,
+ byElement = elementMatchers.length > 0,
+ superMatcher = function( seed, context, xml, results, outermost ) {
+ var elem, j, matcher,
+ matchedCount = 0,
+ i = "0",
+ unmatched = seed && [],
+ setMatched = [],
+ contextBackup = outermostContext,
+ // We must always have either seed elements or outermost context
+ elems = seed || byElement && Expr.find["TAG"]( "*", outermost ),
+ // Use integer dirruns iff this is the outermost matcher
+ dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1),
+ len = elems.length;
+
+ if ( outermost ) {
+ outermostContext = context === document || context || outermost;
+ }
+
+ // Add elements passing elementMatchers directly to results
+ // Support: IE<9, Safari
+ // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id
+ for ( ; i !== len && (elem = elems[i]) != null; i++ ) {
+ if ( byElement && elem ) {
+ j = 0;
+ if ( !context && elem.ownerDocument !== document ) {
+ setDocument( elem );
+ xml = !documentIsHTML;
+ }
+ while ( (matcher = elementMatchers[j++]) ) {
+ if ( matcher( elem, context || document, xml) ) {
+ results.push( elem );
+ break;
+ }
+ }
+ if ( outermost ) {
+ dirruns = dirrunsUnique;
+ }
+ }
+
+ // Track unmatched elements for set filters
+ if ( bySet ) {
+ // They will have gone through all possible matchers
+ if ( (elem = !matcher && elem) ) {
+ matchedCount--;
+ }
+
+ // Lengthen the array for every element, matched or not
+ if ( seed ) {
+ unmatched.push( elem );
+ }
+ }
+ }
+
+ // `i` is now the count of elements visited above, and adding it to `matchedCount`
+ // makes the latter nonnegative.
+ matchedCount += i;
+
+ // Apply set filters to unmatched elements
+ // NOTE: This can be skipped if there are no unmatched elements (i.e., `matchedCount`
+ // equals `i`), unless we didn't visit _any_ elements in the above loop because we have
+ // no element matchers and no seed.
+ // Incrementing an initially-string "0" `i` allows `i` to remain a string only in that
+ // case, which will result in a "00" `matchedCount` that differs from `i` but is also
+ // numerically zero.
+ if ( bySet && i !== matchedCount ) {
+ j = 0;
+ while ( (matcher = setMatchers[j++]) ) {
+ matcher( unmatched, setMatched, context, xml );
+ }
+
+ if ( seed ) {
+ // Reintegrate element matches to eliminate the need for sorting
+ if ( matchedCount > 0 ) {
+ while ( i-- ) {
+ if ( !(unmatched[i] || setMatched[i]) ) {
+ setMatched[i] = pop.call( results );
+ }
+ }
+ }
+
+ // Discard index placeholder values to get only actual matches
+ setMatched = condense( setMatched );
+ }
+
+ // Add matches to results
+ push.apply( results, setMatched );
+
+ // Seedless set matches succeeding multiple successful matchers stipulate sorting
+ if ( outermost && !seed && setMatched.length > 0 &&
+ ( matchedCount + setMatchers.length ) > 1 ) {
+
+ Sizzle.uniqueSort( results );
+ }
+ }
+
+ // Override manipulation of globals by nested matchers
+ if ( outermost ) {
+ dirruns = dirrunsUnique;
+ outermostContext = contextBackup;
+ }
+
+ return unmatched;
+ };
+
+ return bySet ?
+ markFunction( superMatcher ) :
+ superMatcher;
+}
+
+compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) {
+ var i,
+ setMatchers = [],
+ elementMatchers = [],
+ cached = compilerCache[ selector + " " ];
+
+ if ( !cached ) {
+ // Generate a function of recursive functions that can be used to check each element
+ if ( !match ) {
+ match = tokenize( selector );
+ }
+ i = match.length;
+ while ( i-- ) {
+ cached = matcherFromTokens( match[i] );
+ if ( cached[ expando ] ) {
+ setMatchers.push( cached );
+ } else {
+ elementMatchers.push( cached );
+ }
+ }
+
+ // Cache the compiled function
+ cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) );
+
+ // Save selector and tokenization
+ cached.selector = selector;
+ }
+ return cached;
+};
+
+/**
+ * A low-level selection function that works with Sizzle's compiled
+ * selector functions
+ * @param {String|Function} selector A selector or a pre-compiled
+ * selector function built with Sizzle.compile
+ * @param {Element} context
+ * @param {Array} [results]
+ * @param {Array} [seed] A set of elements to match against
+ */
+select = Sizzle.select = function( selector, context, results, seed ) {
+ var i, tokens, token, type, find,
+ compiled = typeof selector === "function" && selector,
+ match = !seed && tokenize( (selector = compiled.selector || selector) );
+
+ results = results || [];
+
+ // Try to minimize operations if there is only one selector in the list and no seed
+ // (the latter of which guarantees us context)
+ if ( match.length === 1 ) {
+
+ // Reduce context if the leading compound selector is an ID
+ tokens = match[0] = match[0].slice( 0 );
+ if ( tokens.length > 2 && (token = tokens[0]).type === "ID" &&
+ support.getById && context.nodeType === 9 && documentIsHTML &&
+ Expr.relative[ tokens[1].type ] ) {
+
+ context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0];
+ if ( !context ) {
+ return results;
+
+ // Precompiled matchers will still verify ancestry, so step up a level
+ } else if ( compiled ) {
+ context = context.parentNode;
+ }
+
+ selector = selector.slice( tokens.shift().value.length );
+ }
+
+ // Fetch a seed set for right-to-left matching
+ i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length;
+ while ( i-- ) {
+ token = tokens[i];
+
+ // Abort if we hit a combinator
+ if ( Expr.relative[ (type = token.type) ] ) {
+ break;
+ }
+ if ( (find = Expr.find[ type ]) ) {
+ // Search, expanding context for leading sibling combinators
+ if ( (seed = find(
+ token.matches[0].replace( runescape, funescape ),
+ rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context
+ )) ) {
+
+ // If seed is empty or no tokens remain, we can return early
+ tokens.splice( i, 1 );
+ selector = seed.length && toSelector( tokens );
+ if ( !selector ) {
+ push.apply( results, seed );
+ return results;
+ }
+
+ break;
+ }
+ }
+ }
+ }
+
+ // Compile and execute a filtering function if one is not provided
+ // Provide `match` to avoid retokenization if we modified the selector above
+ ( compiled || compile( selector, match ) )(
+ seed,
+ context,
+ !documentIsHTML,
+ results,
+ !context || rsibling.test( selector ) && testContext( context.parentNode ) || context
+ );
+ return results;
+};
+
+// One-time assignments
+
+// Sort stability
+support.sortStable = expando.split("").sort( sortOrder ).join("") === expando;
+
+// Support: Chrome 14-35+
+// Always assume duplicates if they aren't passed to the comparison function
+support.detectDuplicates = !!hasDuplicate;
+
+// Initialize against the default document
+setDocument();
+
+// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27)
+// Detached nodes confoundingly follow *each other*
+support.sortDetached = assert(function( div1 ) {
+ // Should return 1, but returns 4 (following)
+ return div1.compareDocumentPosition( document.createElement("div") ) & 1;
+});
+
+// Support: IE<8
+// Prevent attribute/property "interpolation"
+// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx
+if ( !assert(function( div ) {
+ div.innerHTML = " ";
+ return div.firstChild.getAttribute("href") === "#" ;
+}) ) {
+ addHandle( "type|href|height|width", function( elem, name, isXML ) {
+ if ( !isXML ) {
+ return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 );
+ }
+ });
+}
+
+// Support: IE<9
+// Use defaultValue in place of getAttribute("value")
+if ( !support.attributes || !assert(function( div ) {
+ div.innerHTML = " ";
+ div.firstChild.setAttribute( "value", "" );
+ return div.firstChild.getAttribute( "value" ) === "";
+}) ) {
+ addHandle( "value", function( elem, name, isXML ) {
+ if ( !isXML && elem.nodeName.toLowerCase() === "input" ) {
+ return elem.defaultValue;
+ }
+ });
+}
+
+// Support: IE<9
+// Use getAttributeNode to fetch booleans when getAttribute lies
+if ( !assert(function( div ) {
+ return div.getAttribute("disabled") == null;
+}) ) {
+ addHandle( booleans, function( elem, name, isXML ) {
+ var val;
+ if ( !isXML ) {
+ return elem[ name ] === true ? name.toLowerCase() :
+ (val = elem.getAttributeNode( name )) && val.specified ?
+ val.value :
+ null;
+ }
+ });
+}
+
+return Sizzle;
+
+})( window );
+
+
+
+jQuery.find = Sizzle;
+jQuery.expr = Sizzle.selectors;
+jQuery.expr[ ":" ] = jQuery.expr.pseudos;
+jQuery.uniqueSort = jQuery.unique = Sizzle.uniqueSort;
+jQuery.text = Sizzle.getText;
+jQuery.isXMLDoc = Sizzle.isXML;
+jQuery.contains = Sizzle.contains;
+
+
+
+var dir = function( elem, dir, until ) {
+ var matched = [],
+ truncate = until !== undefined;
+
+ while ( ( elem = elem[ dir ] ) && elem.nodeType !== 9 ) {
+ if ( elem.nodeType === 1 ) {
+ if ( truncate && jQuery( elem ).is( until ) ) {
+ break;
+ }
+ matched.push( elem );
+ }
+ }
+ return matched;
+};
+
+
+var siblings = function( n, elem ) {
+ var matched = [];
+
+ for ( ; n; n = n.nextSibling ) {
+ if ( n.nodeType === 1 && n !== elem ) {
+ matched.push( n );
+ }
+ }
+
+ return matched;
+};
+
+
+var rneedsContext = jQuery.expr.match.needsContext;
+
+var rsingleTag = ( /^<([\w-]+)\s*\/?>(?:<\/\1>|)$/ );
+
+
+
+var risSimple = /^.[^:#\[\.,]*$/;
+
+// Implement the identical functionality for filter and not
+function winnow( elements, qualifier, not ) {
+ if ( jQuery.isFunction( qualifier ) ) {
+ return jQuery.grep( elements, function( elem, i ) {
+ /* jshint -W018 */
+ return !!qualifier.call( elem, i, elem ) !== not;
+ } );
+
+ }
+
+ if ( qualifier.nodeType ) {
+ return jQuery.grep( elements, function( elem ) {
+ return ( elem === qualifier ) !== not;
+ } );
+
+ }
+
+ if ( typeof qualifier === "string" ) {
+ if ( risSimple.test( qualifier ) ) {
+ return jQuery.filter( qualifier, elements, not );
+ }
+
+ qualifier = jQuery.filter( qualifier, elements );
+ }
+
+ return jQuery.grep( elements, function( elem ) {
+ return ( indexOf.call( qualifier, elem ) > -1 ) !== not;
+ } );
+}
+
+jQuery.filter = function( expr, elems, not ) {
+ var elem = elems[ 0 ];
+
+ if ( not ) {
+ expr = ":not(" + expr + ")";
+ }
+
+ return elems.length === 1 && elem.nodeType === 1 ?
+ jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] :
+ jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) {
+ return elem.nodeType === 1;
+ } ) );
+};
+
+jQuery.fn.extend( {
+ find: function( selector ) {
+ var i,
+ len = this.length,
+ ret = [],
+ self = this;
+
+ if ( typeof selector !== "string" ) {
+ return this.pushStack( jQuery( selector ).filter( function() {
+ for ( i = 0; i < len; i++ ) {
+ if ( jQuery.contains( self[ i ], this ) ) {
+ return true;
+ }
+ }
+ } ) );
+ }
+
+ for ( i = 0; i < len; i++ ) {
+ jQuery.find( selector, self[ i ], ret );
+ }
+
+ // Needed because $( selector, context ) becomes $( context ).find( selector )
+ ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret );
+ ret.selector = this.selector ? this.selector + " " + selector : selector;
+ return ret;
+ },
+ filter: function( selector ) {
+ return this.pushStack( winnow( this, selector || [], false ) );
+ },
+ not: function( selector ) {
+ return this.pushStack( winnow( this, selector || [], true ) );
+ },
+ is: function( selector ) {
+ return !!winnow(
+ this,
+
+ // If this is a positional/relative selector, check membership in the returned set
+ // so $("p:first").is("p:last") won't return true for a doc with two "p".
+ typeof selector === "string" && rneedsContext.test( selector ) ?
+ jQuery( selector ) :
+ selector || [],
+ false
+ ).length;
+ }
+} );
+
+
+// Initialize a jQuery object
+
+
+// A central reference to the root jQuery(document)
+var rootjQuery,
+
+ // A simple way to check for HTML strings
+ // Prioritize #id over to avoid XSS via location.hash (#9521)
+ // Strict HTML recognition (#11290: must start with <)
+ rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,
+
+ init = jQuery.fn.init = function( selector, context, root ) {
+ var match, elem;
+
+ // HANDLE: $(""), $(null), $(undefined), $(false)
+ if ( !selector ) {
+ return this;
+ }
+
+ // Method init() accepts an alternate rootjQuery
+ // so migrate can support jQuery.sub (gh-2101)
+ root = root || rootjQuery;
+
+ // Handle HTML strings
+ if ( typeof selector === "string" ) {
+ if ( selector[ 0 ] === "<" &&
+ selector[ selector.length - 1 ] === ">" &&
+ selector.length >= 3 ) {
+
+ // Assume that strings that start and end with <> are HTML and skip the regex check
+ match = [ null, selector, null ];
+
+ } else {
+ match = rquickExpr.exec( selector );
+ }
+
+ // Match html or make sure no context is specified for #id
+ if ( match && ( match[ 1 ] || !context ) ) {
+
+ // HANDLE: $(html) -> $(array)
+ if ( match[ 1 ] ) {
+ context = context instanceof jQuery ? context[ 0 ] : context;
+
+ // Option to run scripts is true for back-compat
+ // Intentionally let the error be thrown if parseHTML is not present
+ jQuery.merge( this, jQuery.parseHTML(
+ match[ 1 ],
+ context && context.nodeType ? context.ownerDocument || context : document,
+ true
+ ) );
+
+ // HANDLE: $(html, props)
+ if ( rsingleTag.test( match[ 1 ] ) && jQuery.isPlainObject( context ) ) {
+ for ( match in context ) {
+
+ // Properties of context are called as methods if possible
+ if ( jQuery.isFunction( this[ match ] ) ) {
+ this[ match ]( context[ match ] );
+
+ // ...and otherwise set as attributes
+ } else {
+ this.attr( match, context[ match ] );
+ }
+ }
+ }
+
+ return this;
+
+ // HANDLE: $(#id)
+ } else {
+ elem = document.getElementById( match[ 2 ] );
+
+ // Support: Blackberry 4.6
+ // gEBID returns nodes no longer in the document (#6963)
+ if ( elem && elem.parentNode ) {
+
+ // Inject the element directly into the jQuery object
+ this.length = 1;
+ this[ 0 ] = elem;
+ }
+
+ this.context = document;
+ this.selector = selector;
+ return this;
+ }
+
+ // HANDLE: $(expr, $(...))
+ } else if ( !context || context.jquery ) {
+ return ( context || root ).find( selector );
+
+ // HANDLE: $(expr, context)
+ // (which is just equivalent to: $(context).find(expr)
+ } else {
+ return this.constructor( context ).find( selector );
+ }
+
+ // HANDLE: $(DOMElement)
+ } else if ( selector.nodeType ) {
+ this.context = this[ 0 ] = selector;
+ this.length = 1;
+ return this;
+
+ // HANDLE: $(function)
+ // Shortcut for document ready
+ } else if ( jQuery.isFunction( selector ) ) {
+ return root.ready !== undefined ?
+ root.ready( selector ) :
+
+ // Execute immediately if ready is not present
+ selector( jQuery );
+ }
+
+ if ( selector.selector !== undefined ) {
+ this.selector = selector.selector;
+ this.context = selector.context;
+ }
+
+ return jQuery.makeArray( selector, this );
+ };
+
+// Give the init function the jQuery prototype for later instantiation
+init.prototype = jQuery.fn;
+
+// Initialize central reference
+rootjQuery = jQuery( document );
+
+
+var rparentsprev = /^(?:parents|prev(?:Until|All))/,
+
+ // Methods guaranteed to produce a unique set when starting from a unique set
+ guaranteedUnique = {
+ children: true,
+ contents: true,
+ next: true,
+ prev: true
+ };
+
+jQuery.fn.extend( {
+ has: function( target ) {
+ var targets = jQuery( target, this ),
+ l = targets.length;
+
+ return this.filter( function() {
+ var i = 0;
+ for ( ; i < l; i++ ) {
+ if ( jQuery.contains( this, targets[ i ] ) ) {
+ return true;
+ }
+ }
+ } );
+ },
+
+ closest: function( selectors, context ) {
+ var cur,
+ i = 0,
+ l = this.length,
+ matched = [],
+ pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ?
+ jQuery( selectors, context || this.context ) :
+ 0;
+
+ for ( ; i < l; i++ ) {
+ for ( cur = this[ i ]; cur && cur !== context; cur = cur.parentNode ) {
+
+ // Always skip document fragments
+ if ( cur.nodeType < 11 && ( pos ?
+ pos.index( cur ) > -1 :
+
+ // Don't pass non-elements to Sizzle
+ cur.nodeType === 1 &&
+ jQuery.find.matchesSelector( cur, selectors ) ) ) {
+
+ matched.push( cur );
+ break;
+ }
+ }
+ }
+
+ return this.pushStack( matched.length > 1 ? jQuery.uniqueSort( matched ) : matched );
+ },
+
+ // Determine the position of an element within the set
+ index: function( elem ) {
+
+ // No argument, return index in parent
+ if ( !elem ) {
+ return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1;
+ }
+
+ // Index in selector
+ if ( typeof elem === "string" ) {
+ return indexOf.call( jQuery( elem ), this[ 0 ] );
+ }
+
+ // Locate the position of the desired element
+ return indexOf.call( this,
+
+ // If it receives a jQuery object, the first element is used
+ elem.jquery ? elem[ 0 ] : elem
+ );
+ },
+
+ add: function( selector, context ) {
+ return this.pushStack(
+ jQuery.uniqueSort(
+ jQuery.merge( this.get(), jQuery( selector, context ) )
+ )
+ );
+ },
+
+ addBack: function( selector ) {
+ return this.add( selector == null ?
+ this.prevObject : this.prevObject.filter( selector )
+ );
+ }
+} );
+
+function sibling( cur, dir ) {
+ while ( ( cur = cur[ dir ] ) && cur.nodeType !== 1 ) {}
+ return cur;
+}
+
+jQuery.each( {
+ parent: function( elem ) {
+ var parent = elem.parentNode;
+ return parent && parent.nodeType !== 11 ? parent : null;
+ },
+ parents: function( elem ) {
+ return dir( elem, "parentNode" );
+ },
+ parentsUntil: function( elem, i, until ) {
+ return dir( elem, "parentNode", until );
+ },
+ next: function( elem ) {
+ return sibling( elem, "nextSibling" );
+ },
+ prev: function( elem ) {
+ return sibling( elem, "previousSibling" );
+ },
+ nextAll: function( elem ) {
+ return dir( elem, "nextSibling" );
+ },
+ prevAll: function( elem ) {
+ return dir( elem, "previousSibling" );
+ },
+ nextUntil: function( elem, i, until ) {
+ return dir( elem, "nextSibling", until );
+ },
+ prevUntil: function( elem, i, until ) {
+ return dir( elem, "previousSibling", until );
+ },
+ siblings: function( elem ) {
+ return siblings( ( elem.parentNode || {} ).firstChild, elem );
+ },
+ children: function( elem ) {
+ return siblings( elem.firstChild );
+ },
+ contents: function( elem ) {
+ return elem.contentDocument || jQuery.merge( [], elem.childNodes );
+ }
+}, function( name, fn ) {
+ jQuery.fn[ name ] = function( until, selector ) {
+ var matched = jQuery.map( this, fn, until );
+
+ if ( name.slice( -5 ) !== "Until" ) {
+ selector = until;
+ }
+
+ if ( selector && typeof selector === "string" ) {
+ matched = jQuery.filter( selector, matched );
+ }
+
+ if ( this.length > 1 ) {
+
+ // Remove duplicates
+ if ( !guaranteedUnique[ name ] ) {
+ jQuery.uniqueSort( matched );
+ }
+
+ // Reverse order for parents* and prev-derivatives
+ if ( rparentsprev.test( name ) ) {
+ matched.reverse();
+ }
+ }
+
+ return this.pushStack( matched );
+ };
+} );
+var rnotwhite = ( /\S+/g );
+
+
+
+// Convert String-formatted options into Object-formatted ones
+function createOptions( options ) {
+ var object = {};
+ jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) {
+ object[ flag ] = true;
+ } );
+ return object;
+}
+
+/*
+ * Create a callback list using the following parameters:
+ *
+ * options: an optional list of space-separated options that will change how
+ * the callback list behaves or a more traditional option object
+ *
+ * By default a callback list will act like an event callback list and can be
+ * "fired" multiple times.
+ *
+ * Possible options:
+ *
+ * once: will ensure the callback list can only be fired once (like a Deferred)
+ *
+ * memory: will keep track of previous values and will call any callback added
+ * after the list has been fired right away with the latest "memorized"
+ * values (like a Deferred)
+ *
+ * unique: will ensure a callback can only be added once (no duplicate in the list)
+ *
+ * stopOnFalse: interrupt callings when a callback returns false
+ *
+ */
+jQuery.Callbacks = function( options ) {
+
+ // Convert options from String-formatted to Object-formatted if needed
+ // (we check in cache first)
+ options = typeof options === "string" ?
+ createOptions( options ) :
+ jQuery.extend( {}, options );
+
+ var // Flag to know if list is currently firing
+ firing,
+
+ // Last fire value for non-forgettable lists
+ memory,
+
+ // Flag to know if list was already fired
+ fired,
+
+ // Flag to prevent firing
+ locked,
+
+ // Actual callback list
+ list = [],
+
+ // Queue of execution data for repeatable lists
+ queue = [],
+
+ // Index of currently firing callback (modified by add/remove as needed)
+ firingIndex = -1,
+
+ // Fire callbacks
+ fire = function() {
+
+ // Enforce single-firing
+ locked = options.once;
+
+ // Execute callbacks for all pending executions,
+ // respecting firingIndex overrides and runtime changes
+ fired = firing = true;
+ for ( ; queue.length; firingIndex = -1 ) {
+ memory = queue.shift();
+ while ( ++firingIndex < list.length ) {
+
+ // Run callback and check for early termination
+ if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false &&
+ options.stopOnFalse ) {
+
+ // Jump to end and forget the data so .add doesn't re-fire
+ firingIndex = list.length;
+ memory = false;
+ }
+ }
+ }
+
+ // Forget the data if we're done with it
+ if ( !options.memory ) {
+ memory = false;
+ }
+
+ firing = false;
+
+ // Clean up if we're done firing for good
+ if ( locked ) {
+
+ // Keep an empty list if we have data for future add calls
+ if ( memory ) {
+ list = [];
+
+ // Otherwise, this object is spent
+ } else {
+ list = "";
+ }
+ }
+ },
+
+ // Actual Callbacks object
+ self = {
+
+ // Add a callback or a collection of callbacks to the list
+ add: function() {
+ if ( list ) {
+
+ // If we have memory from a past run, we should fire after adding
+ if ( memory && !firing ) {
+ firingIndex = list.length - 1;
+ queue.push( memory );
+ }
+
+ ( function add( args ) {
+ jQuery.each( args, function( _, arg ) {
+ if ( jQuery.isFunction( arg ) ) {
+ if ( !options.unique || !self.has( arg ) ) {
+ list.push( arg );
+ }
+ } else if ( arg && arg.length && jQuery.type( arg ) !== "string" ) {
+
+ // Inspect recursively
+ add( arg );
+ }
+ } );
+ } )( arguments );
+
+ if ( memory && !firing ) {
+ fire();
+ }
+ }
+ return this;
+ },
+
+ // Remove a callback from the list
+ remove: function() {
+ jQuery.each( arguments, function( _, arg ) {
+ var index;
+ while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) {
+ list.splice( index, 1 );
+
+ // Handle firing indexes
+ if ( index <= firingIndex ) {
+ firingIndex--;
+ }
+ }
+ } );
+ return this;
+ },
+
+ // Check if a given callback is in the list.
+ // If no argument is given, return whether or not list has callbacks attached.
+ has: function( fn ) {
+ return fn ?
+ jQuery.inArray( fn, list ) > -1 :
+ list.length > 0;
+ },
+
+ // Remove all callbacks from the list
+ empty: function() {
+ if ( list ) {
+ list = [];
+ }
+ return this;
+ },
+
+ // Disable .fire and .add
+ // Abort any current/pending executions
+ // Clear all callbacks and values
+ disable: function() {
+ locked = queue = [];
+ list = memory = "";
+ return this;
+ },
+ disabled: function() {
+ return !list;
+ },
+
+ // Disable .fire
+ // Also disable .add unless we have memory (since it would have no effect)
+ // Abort any pending executions
+ lock: function() {
+ locked = queue = [];
+ if ( !memory ) {
+ list = memory = "";
+ }
+ return this;
+ },
+ locked: function() {
+ return !!locked;
+ },
+
+ // Call all callbacks with the given context and arguments
+ fireWith: function( context, args ) {
+ if ( !locked ) {
+ args = args || [];
+ args = [ context, args.slice ? args.slice() : args ];
+ queue.push( args );
+ if ( !firing ) {
+ fire();
+ }
+ }
+ return this;
+ },
+
+ // Call all the callbacks with the given arguments
+ fire: function() {
+ self.fireWith( this, arguments );
+ return this;
+ },
+
+ // To know if the callbacks have already been called at least once
+ fired: function() {
+ return !!fired;
+ }
+ };
+
+ return self;
+};
+
+
+jQuery.extend( {
+
+ Deferred: function( func ) {
+ var tuples = [
+
+ // action, add listener, listener list, final state
+ [ "resolve", "done", jQuery.Callbacks( "once memory" ), "resolved" ],
+ [ "reject", "fail", jQuery.Callbacks( "once memory" ), "rejected" ],
+ [ "notify", "progress", jQuery.Callbacks( "memory" ) ]
+ ],
+ state = "pending",
+ promise = {
+ state: function() {
+ return state;
+ },
+ always: function() {
+ deferred.done( arguments ).fail( arguments );
+ return this;
+ },
+ then: function( /* fnDone, fnFail, fnProgress */ ) {
+ var fns = arguments;
+ return jQuery.Deferred( function( newDefer ) {
+ jQuery.each( tuples, function( i, tuple ) {
+ var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ];
+
+ // deferred[ done | fail | progress ] for forwarding actions to newDefer
+ deferred[ tuple[ 1 ] ]( function() {
+ var returned = fn && fn.apply( this, arguments );
+ if ( returned && jQuery.isFunction( returned.promise ) ) {
+ returned.promise()
+ .progress( newDefer.notify )
+ .done( newDefer.resolve )
+ .fail( newDefer.reject );
+ } else {
+ newDefer[ tuple[ 0 ] + "With" ](
+ this === promise ? newDefer.promise() : this,
+ fn ? [ returned ] : arguments
+ );
+ }
+ } );
+ } );
+ fns = null;
+ } ).promise();
+ },
+
+ // Get a promise for this deferred
+ // If obj is provided, the promise aspect is added to the object
+ promise: function( obj ) {
+ return obj != null ? jQuery.extend( obj, promise ) : promise;
+ }
+ },
+ deferred = {};
+
+ // Keep pipe for back-compat
+ promise.pipe = promise.then;
+
+ // Add list-specific methods
+ jQuery.each( tuples, function( i, tuple ) {
+ var list = tuple[ 2 ],
+ stateString = tuple[ 3 ];
+
+ // promise[ done | fail | progress ] = list.add
+ promise[ tuple[ 1 ] ] = list.add;
+
+ // Handle state
+ if ( stateString ) {
+ list.add( function() {
+
+ // state = [ resolved | rejected ]
+ state = stateString;
+
+ // [ reject_list | resolve_list ].disable; progress_list.lock
+ }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock );
+ }
+
+ // deferred[ resolve | reject | notify ]
+ deferred[ tuple[ 0 ] ] = function() {
+ deferred[ tuple[ 0 ] + "With" ]( this === deferred ? promise : this, arguments );
+ return this;
+ };
+ deferred[ tuple[ 0 ] + "With" ] = list.fireWith;
+ } );
+
+ // Make the deferred a promise
+ promise.promise( deferred );
+
+ // Call given func if any
+ if ( func ) {
+ func.call( deferred, deferred );
+ }
+
+ // All done!
+ return deferred;
+ },
+
+ // Deferred helper
+ when: function( subordinate /* , ..., subordinateN */ ) {
+ var i = 0,
+ resolveValues = slice.call( arguments ),
+ length = resolveValues.length,
+
+ // the count of uncompleted subordinates
+ remaining = length !== 1 ||
+ ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0,
+
+ // the master Deferred.
+ // If resolveValues consist of only a single Deferred, just use that.
+ deferred = remaining === 1 ? subordinate : jQuery.Deferred(),
+
+ // Update function for both resolve and progress values
+ updateFunc = function( i, contexts, values ) {
+ return function( value ) {
+ contexts[ i ] = this;
+ values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value;
+ if ( values === progressValues ) {
+ deferred.notifyWith( contexts, values );
+ } else if ( !( --remaining ) ) {
+ deferred.resolveWith( contexts, values );
+ }
+ };
+ },
+
+ progressValues, progressContexts, resolveContexts;
+
+ // Add listeners to Deferred subordinates; treat others as resolved
+ if ( length > 1 ) {
+ progressValues = new Array( length );
+ progressContexts = new Array( length );
+ resolveContexts = new Array( length );
+ for ( ; i < length; i++ ) {
+ if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) {
+ resolveValues[ i ].promise()
+ .progress( updateFunc( i, progressContexts, progressValues ) )
+ .done( updateFunc( i, resolveContexts, resolveValues ) )
+ .fail( deferred.reject );
+ } else {
+ --remaining;
+ }
+ }
+ }
+
+ // If we're not waiting on anything, resolve the master
+ if ( !remaining ) {
+ deferred.resolveWith( resolveContexts, resolveValues );
+ }
+
+ return deferred.promise();
+ }
+} );
+
+
+// The deferred used on DOM ready
+var readyList;
+
+jQuery.fn.ready = function( fn ) {
+
+ // Add the callback
+ jQuery.ready.promise().done( fn );
+
+ return this;
+};
+
+jQuery.extend( {
+
+ // Is the DOM ready to be used? Set to true once it occurs.
+ isReady: false,
+
+ // A counter to track how many items to wait for before
+ // the ready event fires. See #6781
+ readyWait: 1,
+
+ // Hold (or release) the ready event
+ holdReady: function( hold ) {
+ if ( hold ) {
+ jQuery.readyWait++;
+ } else {
+ jQuery.ready( true );
+ }
+ },
+
+ // Handle when the DOM is ready
+ ready: function( wait ) {
+
+ // Abort if there are pending holds or we're already ready
+ if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) {
+ return;
+ }
+
+ // Remember that the DOM is ready
+ jQuery.isReady = true;
+
+ // If a normal DOM Ready event fired, decrement, and wait if need be
+ if ( wait !== true && --jQuery.readyWait > 0 ) {
+ return;
+ }
+
+ // If there are functions bound, to execute
+ readyList.resolveWith( document, [ jQuery ] );
+
+ // Trigger any bound ready events
+ if ( jQuery.fn.triggerHandler ) {
+ jQuery( document ).triggerHandler( "ready" );
+ jQuery( document ).off( "ready" );
+ }
+ }
+} );
+
+/**
+ * The ready event handler and self cleanup method
+ */
+function completed() {
+ document.removeEventListener( "DOMContentLoaded", completed );
+ window.removeEventListener( "load", completed );
+ jQuery.ready();
+}
+
+jQuery.ready.promise = function( obj ) {
+ if ( !readyList ) {
+
+ readyList = jQuery.Deferred();
+
+ // Catch cases where $(document).ready() is called
+ // after the browser event has already occurred.
+ // Support: IE9-10 only
+ // Older IE sometimes signals "interactive" too soon
+ if ( document.readyState === "complete" ||
+ ( document.readyState !== "loading" && !document.documentElement.doScroll ) ) {
+
+ // Handle it asynchronously to allow scripts the opportunity to delay ready
+ window.setTimeout( jQuery.ready );
+
+ } else {
+
+ // Use the handy event callback
+ document.addEventListener( "DOMContentLoaded", completed );
+
+ // A fallback to window.onload, that will always work
+ window.addEventListener( "load", completed );
+ }
+ }
+ return readyList.promise( obj );
+};
+
+// Kick off the DOM ready check even if the user does not
+jQuery.ready.promise();
+
+
+
+
+// Multifunctional method to get and set values of a collection
+// The value/s can optionally be executed if it's a function
+var access = function( elems, fn, key, value, chainable, emptyGet, raw ) {
+ var i = 0,
+ len = elems.length,
+ bulk = key == null;
+
+ // Sets many values
+ if ( jQuery.type( key ) === "object" ) {
+ chainable = true;
+ for ( i in key ) {
+ access( elems, fn, i, key[ i ], true, emptyGet, raw );
+ }
+
+ // Sets one value
+ } else if ( value !== undefined ) {
+ chainable = true;
+
+ if ( !jQuery.isFunction( value ) ) {
+ raw = true;
+ }
+
+ if ( bulk ) {
+
+ // Bulk operations run against the entire set
+ if ( raw ) {
+ fn.call( elems, value );
+ fn = null;
+
+ // ...except when executing function values
+ } else {
+ bulk = fn;
+ fn = function( elem, key, value ) {
+ return bulk.call( jQuery( elem ), value );
+ };
+ }
+ }
+
+ if ( fn ) {
+ for ( ; i < len; i++ ) {
+ fn(
+ elems[ i ], key, raw ?
+ value :
+ value.call( elems[ i ], i, fn( elems[ i ], key ) )
+ );
+ }
+ }
+ }
+
+ return chainable ?
+ elems :
+
+ // Gets
+ bulk ?
+ fn.call( elems ) :
+ len ? fn( elems[ 0 ], key ) : emptyGet;
+};
+var acceptData = function( owner ) {
+
+ // Accepts only:
+ // - Node
+ // - Node.ELEMENT_NODE
+ // - Node.DOCUMENT_NODE
+ // - Object
+ // - Any
+ /* jshint -W018 */
+ return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType );
+};
+
+
+
+
+function Data() {
+ this.expando = jQuery.expando + Data.uid++;
+}
+
+Data.uid = 1;
+
+Data.prototype = {
+
+ register: function( owner, initial ) {
+ var value = initial || {};
+
+ // If it is a node unlikely to be stringify-ed or looped over
+ // use plain assignment
+ if ( owner.nodeType ) {
+ owner[ this.expando ] = value;
+
+ // Otherwise secure it in a non-enumerable, non-writable property
+ // configurability must be true to allow the property to be
+ // deleted with the delete operator
+ } else {
+ Object.defineProperty( owner, this.expando, {
+ value: value,
+ writable: true,
+ configurable: true
+ } );
+ }
+ return owner[ this.expando ];
+ },
+ cache: function( owner ) {
+
+ // We can accept data for non-element nodes in modern browsers,
+ // but we should not, see #8335.
+ // Always return an empty object.
+ if ( !acceptData( owner ) ) {
+ return {};
+ }
+
+ // Check if the owner object already has a cache
+ var value = owner[ this.expando ];
+
+ // If not, create one
+ if ( !value ) {
+ value = {};
+
+ // We can accept data for non-element nodes in modern browsers,
+ // but we should not, see #8335.
+ // Always return an empty object.
+ if ( acceptData( owner ) ) {
+
+ // If it is a node unlikely to be stringify-ed or looped over
+ // use plain assignment
+ if ( owner.nodeType ) {
+ owner[ this.expando ] = value;
+
+ // Otherwise secure it in a non-enumerable property
+ // configurable must be true to allow the property to be
+ // deleted when data is removed
+ } else {
+ Object.defineProperty( owner, this.expando, {
+ value: value,
+ configurable: true
+ } );
+ }
+ }
+ }
+
+ return value;
+ },
+ set: function( owner, data, value ) {
+ var prop,
+ cache = this.cache( owner );
+
+ // Handle: [ owner, key, value ] args
+ if ( typeof data === "string" ) {
+ cache[ data ] = value;
+
+ // Handle: [ owner, { properties } ] args
+ } else {
+
+ // Copy the properties one-by-one to the cache object
+ for ( prop in data ) {
+ cache[ prop ] = data[ prop ];
+ }
+ }
+ return cache;
+ },
+ get: function( owner, key ) {
+ return key === undefined ?
+ this.cache( owner ) :
+ owner[ this.expando ] && owner[ this.expando ][ key ];
+ },
+ access: function( owner, key, value ) {
+ var stored;
+
+ // In cases where either:
+ //
+ // 1. No key was specified
+ // 2. A string key was specified, but no value provided
+ //
+ // Take the "read" path and allow the get method to determine
+ // which value to return, respectively either:
+ //
+ // 1. The entire cache object
+ // 2. The data stored at the key
+ //
+ if ( key === undefined ||
+ ( ( key && typeof key === "string" ) && value === undefined ) ) {
+
+ stored = this.get( owner, key );
+
+ return stored !== undefined ?
+ stored : this.get( owner, jQuery.camelCase( key ) );
+ }
+
+ // When the key is not a string, or both a key and value
+ // are specified, set or extend (existing objects) with either:
+ //
+ // 1. An object of properties
+ // 2. A key and value
+ //
+ this.set( owner, key, value );
+
+ // Since the "set" path can have two possible entry points
+ // return the expected data based on which path was taken[*]
+ return value !== undefined ? value : key;
+ },
+ remove: function( owner, key ) {
+ var i, name, camel,
+ cache = owner[ this.expando ];
+
+ if ( cache === undefined ) {
+ return;
+ }
+
+ if ( key === undefined ) {
+ this.register( owner );
+
+ } else {
+
+ // Support array or space separated string of keys
+ if ( jQuery.isArray( key ) ) {
+
+ // If "name" is an array of keys...
+ // When data is initially created, via ("key", "val") signature,
+ // keys will be converted to camelCase.
+ // Since there is no way to tell _how_ a key was added, remove
+ // both plain key and camelCase key. #12786
+ // This will only penalize the array argument path.
+ name = key.concat( key.map( jQuery.camelCase ) );
+ } else {
+ camel = jQuery.camelCase( key );
+
+ // Try the string as a key before any manipulation
+ if ( key in cache ) {
+ name = [ key, camel ];
+ } else {
+
+ // If a key with the spaces exists, use it.
+ // Otherwise, create an array by matching non-whitespace
+ name = camel;
+ name = name in cache ?
+ [ name ] : ( name.match( rnotwhite ) || [] );
+ }
+ }
+
+ i = name.length;
+
+ while ( i-- ) {
+ delete cache[ name[ i ] ];
+ }
+ }
+
+ // Remove the expando if there's no more data
+ if ( key === undefined || jQuery.isEmptyObject( cache ) ) {
+
+ // Support: Chrome <= 35-45+
+ // Webkit & Blink performance suffers when isDeleting properties
+ // from DOM nodes, so set to undefined instead
+ // https://code.google.com/p/chromium/issues/detail?id=378607
+ if ( owner.nodeType ) {
+ owner[ this.expando ] = undefined;
+ } else {
+ delete owner[ this.expando ];
+ }
+ }
+ },
+ hasData: function( owner ) {
+ var cache = owner[ this.expando ];
+ return cache !== undefined && !jQuery.isEmptyObject( cache );
+ }
+};
+var dataPriv = new Data();
+
+var dataUser = new Data();
+
+
+
+// Implementation Summary
+//
+// 1. Enforce API surface and semantic compatibility with 1.9.x branch
+// 2. Improve the module's maintainability by reducing the storage
+// paths to a single mechanism.
+// 3. Use the same single mechanism to support "private" and "user" data.
+// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData)
+// 5. Avoid exposing implementation details on user objects (eg. expando properties)
+// 6. Provide a clear path for implementation upgrade to WeakMap in 2014
+
+var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,
+ rmultiDash = /[A-Z]/g;
+
+function dataAttr( elem, key, data ) {
+ var name;
+
+ // If nothing was found internally, try to fetch any
+ // data from the HTML5 data-* attribute
+ if ( data === undefined && elem.nodeType === 1 ) {
+ name = "data-" + key.replace( rmultiDash, "-$&" ).toLowerCase();
+ data = elem.getAttribute( name );
+
+ if ( typeof data === "string" ) {
+ try {
+ data = data === "true" ? true :
+ data === "false" ? false :
+ data === "null" ? null :
+
+ // Only convert to a number if it doesn't change the string
+ +data + "" === data ? +data :
+ rbrace.test( data ) ? jQuery.parseJSON( data ) :
+ data;
+ } catch ( e ) {}
+
+ // Make sure we set the data so it isn't changed later
+ dataUser.set( elem, key, data );
+ } else {
+ data = undefined;
+ }
+ }
+ return data;
+}
+
+jQuery.extend( {
+ hasData: function( elem ) {
+ return dataUser.hasData( elem ) || dataPriv.hasData( elem );
+ },
+
+ data: function( elem, name, data ) {
+ return dataUser.access( elem, name, data );
+ },
+
+ removeData: function( elem, name ) {
+ dataUser.remove( elem, name );
+ },
+
+ // TODO: Now that all calls to _data and _removeData have been replaced
+ // with direct calls to dataPriv methods, these can be deprecated.
+ _data: function( elem, name, data ) {
+ return dataPriv.access( elem, name, data );
+ },
+
+ _removeData: function( elem, name ) {
+ dataPriv.remove( elem, name );
+ }
+} );
+
+jQuery.fn.extend( {
+ data: function( key, value ) {
+ var i, name, data,
+ elem = this[ 0 ],
+ attrs = elem && elem.attributes;
+
+ // Gets all values
+ if ( key === undefined ) {
+ if ( this.length ) {
+ data = dataUser.get( elem );
+
+ if ( elem.nodeType === 1 && !dataPriv.get( elem, "hasDataAttrs" ) ) {
+ i = attrs.length;
+ while ( i-- ) {
+
+ // Support: IE11+
+ // The attrs elements can be null (#14894)
+ if ( attrs[ i ] ) {
+ name = attrs[ i ].name;
+ if ( name.indexOf( "data-" ) === 0 ) {
+ name = jQuery.camelCase( name.slice( 5 ) );
+ dataAttr( elem, name, data[ name ] );
+ }
+ }
+ }
+ dataPriv.set( elem, "hasDataAttrs", true );
+ }
+ }
+
+ return data;
+ }
+
+ // Sets multiple values
+ if ( typeof key === "object" ) {
+ return this.each( function() {
+ dataUser.set( this, key );
+ } );
+ }
+
+ return access( this, function( value ) {
+ var data, camelKey;
+
+ // The calling jQuery object (element matches) is not empty
+ // (and therefore has an element appears at this[ 0 ]) and the
+ // `value` parameter was not undefined. An empty jQuery object
+ // will result in `undefined` for elem = this[ 0 ] which will
+ // throw an exception if an attempt to read a data cache is made.
+ if ( elem && value === undefined ) {
+
+ // Attempt to get data from the cache
+ // with the key as-is
+ data = dataUser.get( elem, key ) ||
+
+ // Try to find dashed key if it exists (gh-2779)
+ // This is for 2.2.x only
+ dataUser.get( elem, key.replace( rmultiDash, "-$&" ).toLowerCase() );
+
+ if ( data !== undefined ) {
+ return data;
+ }
+
+ camelKey = jQuery.camelCase( key );
+
+ // Attempt to get data from the cache
+ // with the key camelized
+ data = dataUser.get( elem, camelKey );
+ if ( data !== undefined ) {
+ return data;
+ }
+
+ // Attempt to "discover" the data in
+ // HTML5 custom data-* attrs
+ data = dataAttr( elem, camelKey, undefined );
+ if ( data !== undefined ) {
+ return data;
+ }
+
+ // We tried really hard, but the data doesn't exist.
+ return;
+ }
+
+ // Set the data...
+ camelKey = jQuery.camelCase( key );
+ this.each( function() {
+
+ // First, attempt to store a copy or reference of any
+ // data that might've been store with a camelCased key.
+ var data = dataUser.get( this, camelKey );
+
+ // For HTML5 data-* attribute interop, we have to
+ // store property names with dashes in a camelCase form.
+ // This might not apply to all properties...*
+ dataUser.set( this, camelKey, value );
+
+ // *... In the case of properties that might _actually_
+ // have dashes, we need to also store a copy of that
+ // unchanged property.
+ if ( key.indexOf( "-" ) > -1 && data !== undefined ) {
+ dataUser.set( this, key, value );
+ }
+ } );
+ }, null, value, arguments.length > 1, null, true );
+ },
+
+ removeData: function( key ) {
+ return this.each( function() {
+ dataUser.remove( this, key );
+ } );
+ }
+} );
+
+
+jQuery.extend( {
+ queue: function( elem, type, data ) {
+ var queue;
+
+ if ( elem ) {
+ type = ( type || "fx" ) + "queue";
+ queue = dataPriv.get( elem, type );
+
+ // Speed up dequeue by getting out quickly if this is just a lookup
+ if ( data ) {
+ if ( !queue || jQuery.isArray( data ) ) {
+ queue = dataPriv.access( elem, type, jQuery.makeArray( data ) );
+ } else {
+ queue.push( data );
+ }
+ }
+ return queue || [];
+ }
+ },
+
+ dequeue: function( elem, type ) {
+ type = type || "fx";
+
+ var queue = jQuery.queue( elem, type ),
+ startLength = queue.length,
+ fn = queue.shift(),
+ hooks = jQuery._queueHooks( elem, type ),
+ next = function() {
+ jQuery.dequeue( elem, type );
+ };
+
+ // If the fx queue is dequeued, always remove the progress sentinel
+ if ( fn === "inprogress" ) {
+ fn = queue.shift();
+ startLength--;
+ }
+
+ if ( fn ) {
+
+ // Add a progress sentinel to prevent the fx queue from being
+ // automatically dequeued
+ if ( type === "fx" ) {
+ queue.unshift( "inprogress" );
+ }
+
+ // Clear up the last queue stop function
+ delete hooks.stop;
+ fn.call( elem, next, hooks );
+ }
+
+ if ( !startLength && hooks ) {
+ hooks.empty.fire();
+ }
+ },
+
+ // Not public - generate a queueHooks object, or return the current one
+ _queueHooks: function( elem, type ) {
+ var key = type + "queueHooks";
+ return dataPriv.get( elem, key ) || dataPriv.access( elem, key, {
+ empty: jQuery.Callbacks( "once memory" ).add( function() {
+ dataPriv.remove( elem, [ type + "queue", key ] );
+ } )
+ } );
+ }
+} );
+
+jQuery.fn.extend( {
+ queue: function( type, data ) {
+ var setter = 2;
+
+ if ( typeof type !== "string" ) {
+ data = type;
+ type = "fx";
+ setter--;
+ }
+
+ if ( arguments.length < setter ) {
+ return jQuery.queue( this[ 0 ], type );
+ }
+
+ return data === undefined ?
+ this :
+ this.each( function() {
+ var queue = jQuery.queue( this, type, data );
+
+ // Ensure a hooks for this queue
+ jQuery._queueHooks( this, type );
+
+ if ( type === "fx" && queue[ 0 ] !== "inprogress" ) {
+ jQuery.dequeue( this, type );
+ }
+ } );
+ },
+ dequeue: function( type ) {
+ return this.each( function() {
+ jQuery.dequeue( this, type );
+ } );
+ },
+ clearQueue: function( type ) {
+ return this.queue( type || "fx", [] );
+ },
+
+ // Get a promise resolved when queues of a certain type
+ // are emptied (fx is the type by default)
+ promise: function( type, obj ) {
+ var tmp,
+ count = 1,
+ defer = jQuery.Deferred(),
+ elements = this,
+ i = this.length,
+ resolve = function() {
+ if ( !( --count ) ) {
+ defer.resolveWith( elements, [ elements ] );
+ }
+ };
+
+ if ( typeof type !== "string" ) {
+ obj = type;
+ type = undefined;
+ }
+ type = type || "fx";
+
+ while ( i-- ) {
+ tmp = dataPriv.get( elements[ i ], type + "queueHooks" );
+ if ( tmp && tmp.empty ) {
+ count++;
+ tmp.empty.add( resolve );
+ }
+ }
+ resolve();
+ return defer.promise( obj );
+ }
+} );
+var pnum = ( /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/ ).source;
+
+var rcssNum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" );
+
+
+var cssExpand = [ "Top", "Right", "Bottom", "Left" ];
+
+var isHidden = function( elem, el ) {
+
+ // isHidden might be called from jQuery#filter function;
+ // in that case, element will be second argument
+ elem = el || elem;
+ return jQuery.css( elem, "display" ) === "none" ||
+ !jQuery.contains( elem.ownerDocument, elem );
+ };
+
+
+
+function adjustCSS( elem, prop, valueParts, tween ) {
+ var adjusted,
+ scale = 1,
+ maxIterations = 20,
+ currentValue = tween ?
+ function() { return tween.cur(); } :
+ function() { return jQuery.css( elem, prop, "" ); },
+ initial = currentValue(),
+ unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ),
+
+ // Starting value computation is required for potential unit mismatches
+ initialInUnit = ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) &&
+ rcssNum.exec( jQuery.css( elem, prop ) );
+
+ if ( initialInUnit && initialInUnit[ 3 ] !== unit ) {
+
+ // Trust units reported by jQuery.css
+ unit = unit || initialInUnit[ 3 ];
+
+ // Make sure we update the tween properties later on
+ valueParts = valueParts || [];
+
+ // Iteratively approximate from a nonzero starting point
+ initialInUnit = +initial || 1;
+
+ do {
+
+ // If previous iteration zeroed out, double until we get *something*.
+ // Use string for doubling so we don't accidentally see scale as unchanged below
+ scale = scale || ".5";
+
+ // Adjust and apply
+ initialInUnit = initialInUnit / scale;
+ jQuery.style( elem, prop, initialInUnit + unit );
+
+ // Update scale, tolerating zero or NaN from tween.cur()
+ // Break the loop if scale is unchanged or perfect, or if we've just had enough.
+ } while (
+ scale !== ( scale = currentValue() / initial ) && scale !== 1 && --maxIterations
+ );
+ }
+
+ if ( valueParts ) {
+ initialInUnit = +initialInUnit || +initial || 0;
+
+ // Apply relative offset (+=/-=) if specified
+ adjusted = valueParts[ 1 ] ?
+ initialInUnit + ( valueParts[ 1 ] + 1 ) * valueParts[ 2 ] :
+ +valueParts[ 2 ];
+ if ( tween ) {
+ tween.unit = unit;
+ tween.start = initialInUnit;
+ tween.end = adjusted;
+ }
+ }
+ return adjusted;
+}
+var rcheckableType = ( /^(?:checkbox|radio)$/i );
+
+var rtagName = ( /<([\w:-]+)/ );
+
+var rscriptType = ( /^$|\/(?:java|ecma)script/i );
+
+
+
+// We have to close these tags to support XHTML (#13200)
+var wrapMap = {
+
+ // Support: IE9
+ option: [ 1, "", " " ],
+
+ // XHTML parsers do not magically insert elements in the
+ // same way that tag soup parsers do. So we cannot shorten
+ // this by omitting or other required elements.
+ thead: [ 1, "" ],
+ col: [ 2, "" ],
+ tr: [ 2, "" ],
+ td: [ 3, "" ],
+
+ _default: [ 0, "", "" ]
+};
+
+// Support: IE9
+wrapMap.optgroup = wrapMap.option;
+
+wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
+wrapMap.th = wrapMap.td;
+
+
+function getAll( context, tag ) {
+
+ // Support: IE9-11+
+ // Use typeof to avoid zero-argument method invocation on host objects (#15151)
+ var ret = typeof context.getElementsByTagName !== "undefined" ?
+ context.getElementsByTagName( tag || "*" ) :
+ typeof context.querySelectorAll !== "undefined" ?
+ context.querySelectorAll( tag || "*" ) :
+ [];
+
+ return tag === undefined || tag && jQuery.nodeName( context, tag ) ?
+ jQuery.merge( [ context ], ret ) :
+ ret;
+}
+
+
+// Mark scripts as having already been evaluated
+function setGlobalEval( elems, refElements ) {
+ var i = 0,
+ l = elems.length;
+
+ for ( ; i < l; i++ ) {
+ dataPriv.set(
+ elems[ i ],
+ "globalEval",
+ !refElements || dataPriv.get( refElements[ i ], "globalEval" )
+ );
+ }
+}
+
+
+var rhtml = /<|?\w+;/;
+
+function buildFragment( elems, context, scripts, selection, ignored ) {
+ var elem, tmp, tag, wrap, contains, j,
+ fragment = context.createDocumentFragment(),
+ nodes = [],
+ i = 0,
+ l = elems.length;
+
+ for ( ; i < l; i++ ) {
+ elem = elems[ i ];
+
+ if ( elem || elem === 0 ) {
+
+ // Add nodes directly
+ if ( jQuery.type( elem ) === "object" ) {
+
+ // Support: Android<4.1, PhantomJS<2
+ // push.apply(_, arraylike) throws on ancient WebKit
+ jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem );
+
+ // Convert non-html into a text node
+ } else if ( !rhtml.test( elem ) ) {
+ nodes.push( context.createTextNode( elem ) );
+
+ // Convert html into DOM nodes
+ } else {
+ tmp = tmp || fragment.appendChild( context.createElement( "div" ) );
+
+ // Deserialize a standard representation
+ tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase();
+ wrap = wrapMap[ tag ] || wrapMap._default;
+ tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ];
+
+ // Descend through wrappers to the right content
+ j = wrap[ 0 ];
+ while ( j-- ) {
+ tmp = tmp.lastChild;
+ }
+
+ // Support: Android<4.1, PhantomJS<2
+ // push.apply(_, arraylike) throws on ancient WebKit
+ jQuery.merge( nodes, tmp.childNodes );
+
+ // Remember the top-level container
+ tmp = fragment.firstChild;
+
+ // Ensure the created nodes are orphaned (#12392)
+ tmp.textContent = "";
+ }
+ }
+ }
+
+ // Remove wrapper from fragment
+ fragment.textContent = "";
+
+ i = 0;
+ while ( ( elem = nodes[ i++ ] ) ) {
+
+ // Skip elements already in the context collection (trac-4087)
+ if ( selection && jQuery.inArray( elem, selection ) > -1 ) {
+ if ( ignored ) {
+ ignored.push( elem );
+ }
+ continue;
+ }
+
+ contains = jQuery.contains( elem.ownerDocument, elem );
+
+ // Append to fragment
+ tmp = getAll( fragment.appendChild( elem ), "script" );
+
+ // Preserve script evaluation history
+ if ( contains ) {
+ setGlobalEval( tmp );
+ }
+
+ // Capture executables
+ if ( scripts ) {
+ j = 0;
+ while ( ( elem = tmp[ j++ ] ) ) {
+ if ( rscriptType.test( elem.type || "" ) ) {
+ scripts.push( elem );
+ }
+ }
+ }
+ }
+
+ return fragment;
+}
+
+
+( function() {
+ var fragment = document.createDocumentFragment(),
+ div = fragment.appendChild( document.createElement( "div" ) ),
+ input = document.createElement( "input" );
+
+ // Support: Android 4.0-4.3, Safari<=5.1
+ // Check state lost if the name is set (#11217)
+ // Support: Windows Web Apps (WWA)
+ // `name` and `type` must use .setAttribute for WWA (#14901)
+ input.setAttribute( "type", "radio" );
+ input.setAttribute( "checked", "checked" );
+ input.setAttribute( "name", "t" );
+
+ div.appendChild( input );
+
+ // Support: Safari<=5.1, Android<4.2
+ // Older WebKit doesn't clone checked state correctly in fragments
+ support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked;
+
+ // Support: IE<=11+
+ // Make sure textarea (and checkbox) defaultValue is properly cloned
+ div.innerHTML = "";
+ support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue;
+} )();
+
+
+var
+ rkeyEvent = /^key/,
+ rmouseEvent = /^(?:mouse|pointer|contextmenu|drag|drop)|click/,
+ rtypenamespace = /^([^.]*)(?:\.(.+)|)/;
+
+function returnTrue() {
+ return true;
+}
+
+function returnFalse() {
+ return false;
+}
+
+// Support: IE9
+// See #13393 for more info
+function safeActiveElement() {
+ try {
+ return document.activeElement;
+ } catch ( err ) { }
+}
+
+function on( elem, types, selector, data, fn, one ) {
+ var origFn, type;
+
+ // Types can be a map of types/handlers
+ if ( typeof types === "object" ) {
+
+ // ( types-Object, selector, data )
+ if ( typeof selector !== "string" ) {
+
+ // ( types-Object, data )
+ data = data || selector;
+ selector = undefined;
+ }
+ for ( type in types ) {
+ on( elem, type, selector, data, types[ type ], one );
+ }
+ return elem;
+ }
+
+ if ( data == null && fn == null ) {
+
+ // ( types, fn )
+ fn = selector;
+ data = selector = undefined;
+ } else if ( fn == null ) {
+ if ( typeof selector === "string" ) {
+
+ // ( types, selector, fn )
+ fn = data;
+ data = undefined;
+ } else {
+
+ // ( types, data, fn )
+ fn = data;
+ data = selector;
+ selector = undefined;
+ }
+ }
+ if ( fn === false ) {
+ fn = returnFalse;
+ } else if ( !fn ) {
+ return elem;
+ }
+
+ if ( one === 1 ) {
+ origFn = fn;
+ fn = function( event ) {
+
+ // Can use an empty set, since event contains the info
+ jQuery().off( event );
+ return origFn.apply( this, arguments );
+ };
+
+ // Use same guid so caller can remove using origFn
+ fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ );
+ }
+ return elem.each( function() {
+ jQuery.event.add( this, types, fn, data, selector );
+ } );
+}
+
+/*
+ * Helper functions for managing events -- not part of the public interface.
+ * Props to Dean Edwards' addEvent library for many of the ideas.
+ */
+jQuery.event = {
+
+ global: {},
+
+ add: function( elem, types, handler, data, selector ) {
+
+ var handleObjIn, eventHandle, tmp,
+ events, t, handleObj,
+ special, handlers, type, namespaces, origType,
+ elemData = dataPriv.get( elem );
+
+ // Don't attach events to noData or text/comment nodes (but allow plain objects)
+ if ( !elemData ) {
+ return;
+ }
+
+ // Caller can pass in an object of custom data in lieu of the handler
+ if ( handler.handler ) {
+ handleObjIn = handler;
+ handler = handleObjIn.handler;
+ selector = handleObjIn.selector;
+ }
+
+ // Make sure that the handler has a unique ID, used to find/remove it later
+ if ( !handler.guid ) {
+ handler.guid = jQuery.guid++;
+ }
+
+ // Init the element's event structure and main handler, if this is the first
+ if ( !( events = elemData.events ) ) {
+ events = elemData.events = {};
+ }
+ if ( !( eventHandle = elemData.handle ) ) {
+ eventHandle = elemData.handle = function( e ) {
+
+ // Discard the second event of a jQuery.event.trigger() and
+ // when an event is called after a page has unloaded
+ return typeof jQuery !== "undefined" && jQuery.event.triggered !== e.type ?
+ jQuery.event.dispatch.apply( elem, arguments ) : undefined;
+ };
+ }
+
+ // Handle multiple events separated by a space
+ types = ( types || "" ).match( rnotwhite ) || [ "" ];
+ t = types.length;
+ while ( t-- ) {
+ tmp = rtypenamespace.exec( types[ t ] ) || [];
+ type = origType = tmp[ 1 ];
+ namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort();
+
+ // There *must* be a type, no attaching namespace-only handlers
+ if ( !type ) {
+ continue;
+ }
+
+ // If event changes its type, use the special event handlers for the changed type
+ special = jQuery.event.special[ type ] || {};
+
+ // If selector defined, determine special event api type, otherwise given type
+ type = ( selector ? special.delegateType : special.bindType ) || type;
+
+ // Update special based on newly reset type
+ special = jQuery.event.special[ type ] || {};
+
+ // handleObj is passed to all event handlers
+ handleObj = jQuery.extend( {
+ type: type,
+ origType: origType,
+ data: data,
+ handler: handler,
+ guid: handler.guid,
+ selector: selector,
+ needsContext: selector && jQuery.expr.match.needsContext.test( selector ),
+ namespace: namespaces.join( "." )
+ }, handleObjIn );
+
+ // Init the event handler queue if we're the first
+ if ( !( handlers = events[ type ] ) ) {
+ handlers = events[ type ] = [];
+ handlers.delegateCount = 0;
+
+ // Only use addEventListener if the special events handler returns false
+ if ( !special.setup ||
+ special.setup.call( elem, data, namespaces, eventHandle ) === false ) {
+
+ if ( elem.addEventListener ) {
+ elem.addEventListener( type, eventHandle );
+ }
+ }
+ }
+
+ if ( special.add ) {
+ special.add.call( elem, handleObj );
+
+ if ( !handleObj.handler.guid ) {
+ handleObj.handler.guid = handler.guid;
+ }
+ }
+
+ // Add to the element's handler list, delegates in front
+ if ( selector ) {
+ handlers.splice( handlers.delegateCount++, 0, handleObj );
+ } else {
+ handlers.push( handleObj );
+ }
+
+ // Keep track of which events have ever been used, for event optimization
+ jQuery.event.global[ type ] = true;
+ }
+
+ },
+
+ // Detach an event or set of events from an element
+ remove: function( elem, types, handler, selector, mappedTypes ) {
+
+ var j, origCount, tmp,
+ events, t, handleObj,
+ special, handlers, type, namespaces, origType,
+ elemData = dataPriv.hasData( elem ) && dataPriv.get( elem );
+
+ if ( !elemData || !( events = elemData.events ) ) {
+ return;
+ }
+
+ // Once for each type.namespace in types; type may be omitted
+ types = ( types || "" ).match( rnotwhite ) || [ "" ];
+ t = types.length;
+ while ( t-- ) {
+ tmp = rtypenamespace.exec( types[ t ] ) || [];
+ type = origType = tmp[ 1 ];
+ namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort();
+
+ // Unbind all events (on this namespace, if provided) for the element
+ if ( !type ) {
+ for ( type in events ) {
+ jQuery.event.remove( elem, type + types[ t ], handler, selector, true );
+ }
+ continue;
+ }
+
+ special = jQuery.event.special[ type ] || {};
+ type = ( selector ? special.delegateType : special.bindType ) || type;
+ handlers = events[ type ] || [];
+ tmp = tmp[ 2 ] &&
+ new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" );
+
+ // Remove matching events
+ origCount = j = handlers.length;
+ while ( j-- ) {
+ handleObj = handlers[ j ];
+
+ if ( ( mappedTypes || origType === handleObj.origType ) &&
+ ( !handler || handler.guid === handleObj.guid ) &&
+ ( !tmp || tmp.test( handleObj.namespace ) ) &&
+ ( !selector || selector === handleObj.selector ||
+ selector === "**" && handleObj.selector ) ) {
+ handlers.splice( j, 1 );
+
+ if ( handleObj.selector ) {
+ handlers.delegateCount--;
+ }
+ if ( special.remove ) {
+ special.remove.call( elem, handleObj );
+ }
+ }
+ }
+
+ // Remove generic event handler if we removed something and no more handlers exist
+ // (avoids potential for endless recursion during removal of special event handlers)
+ if ( origCount && !handlers.length ) {
+ if ( !special.teardown ||
+ special.teardown.call( elem, namespaces, elemData.handle ) === false ) {
+
+ jQuery.removeEvent( elem, type, elemData.handle );
+ }
+
+ delete events[ type ];
+ }
+ }
+
+ // Remove data and the expando if it's no longer used
+ if ( jQuery.isEmptyObject( events ) ) {
+ dataPriv.remove( elem, "handle events" );
+ }
+ },
+
+ dispatch: function( event ) {
+
+ // Make a writable jQuery.Event from the native event object
+ event = jQuery.event.fix( event );
+
+ var i, j, ret, matched, handleObj,
+ handlerQueue = [],
+ args = slice.call( arguments ),
+ handlers = ( dataPriv.get( this, "events" ) || {} )[ event.type ] || [],
+ special = jQuery.event.special[ event.type ] || {};
+
+ // Use the fix-ed jQuery.Event rather than the (read-only) native event
+ args[ 0 ] = event;
+ event.delegateTarget = this;
+
+ // Call the preDispatch hook for the mapped type, and let it bail if desired
+ if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {
+ return;
+ }
+
+ // Determine handlers
+ handlerQueue = jQuery.event.handlers.call( this, event, handlers );
+
+ // Run delegates first; they may want to stop propagation beneath us
+ i = 0;
+ while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) {
+ event.currentTarget = matched.elem;
+
+ j = 0;
+ while ( ( handleObj = matched.handlers[ j++ ] ) &&
+ !event.isImmediatePropagationStopped() ) {
+
+ // Triggered event must either 1) have no namespace, or 2) have namespace(s)
+ // a subset or equal to those in the bound event (both can have no namespace).
+ if ( !event.rnamespace || event.rnamespace.test( handleObj.namespace ) ) {
+
+ event.handleObj = handleObj;
+ event.data = handleObj.data;
+
+ ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle ||
+ handleObj.handler ).apply( matched.elem, args );
+
+ if ( ret !== undefined ) {
+ if ( ( event.result = ret ) === false ) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+ }
+
+ // Call the postDispatch hook for the mapped type
+ if ( special.postDispatch ) {
+ special.postDispatch.call( this, event );
+ }
+
+ return event.result;
+ },
+
+ handlers: function( event, handlers ) {
+ var i, matches, sel, handleObj,
+ handlerQueue = [],
+ delegateCount = handlers.delegateCount,
+ cur = event.target;
+
+ // Support (at least): Chrome, IE9
+ // Find delegate handlers
+ // Black-hole SVG instance trees (#13180)
+ //
+ // Support: Firefox<=42+
+ // Avoid non-left-click in FF but don't block IE radio events (#3861, gh-2343)
+ if ( delegateCount && cur.nodeType &&
+ ( event.type !== "click" || isNaN( event.button ) || event.button < 1 ) ) {
+
+ for ( ; cur !== this; cur = cur.parentNode || this ) {
+
+ // Don't check non-elements (#13208)
+ // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764)
+ if ( cur.nodeType === 1 && ( cur.disabled !== true || event.type !== "click" ) ) {
+ matches = [];
+ for ( i = 0; i < delegateCount; i++ ) {
+ handleObj = handlers[ i ];
+
+ // Don't conflict with Object.prototype properties (#13203)
+ sel = handleObj.selector + " ";
+
+ if ( matches[ sel ] === undefined ) {
+ matches[ sel ] = handleObj.needsContext ?
+ jQuery( sel, this ).index( cur ) > -1 :
+ jQuery.find( sel, this, null, [ cur ] ).length;
+ }
+ if ( matches[ sel ] ) {
+ matches.push( handleObj );
+ }
+ }
+ if ( matches.length ) {
+ handlerQueue.push( { elem: cur, handlers: matches } );
+ }
+ }
+ }
+ }
+
+ // Add the remaining (directly-bound) handlers
+ if ( delegateCount < handlers.length ) {
+ handlerQueue.push( { elem: this, handlers: handlers.slice( delegateCount ) } );
+ }
+
+ return handlerQueue;
+ },
+
+ // Includes some event props shared by KeyEvent and MouseEvent
+ props: ( "altKey bubbles cancelable ctrlKey currentTarget detail eventPhase " +
+ "metaKey relatedTarget shiftKey target timeStamp view which" ).split( " " ),
+
+ fixHooks: {},
+
+ keyHooks: {
+ props: "char charCode key keyCode".split( " " ),
+ filter: function( event, original ) {
+
+ // Add which for key events
+ if ( event.which == null ) {
+ event.which = original.charCode != null ? original.charCode : original.keyCode;
+ }
+
+ return event;
+ }
+ },
+
+ mouseHooks: {
+ props: ( "button buttons clientX clientY offsetX offsetY pageX pageY " +
+ "screenX screenY toElement" ).split( " " ),
+ filter: function( event, original ) {
+ var eventDoc, doc, body,
+ button = original.button;
+
+ // Calculate pageX/Y if missing and clientX/Y available
+ if ( event.pageX == null && original.clientX != null ) {
+ eventDoc = event.target.ownerDocument || document;
+ doc = eventDoc.documentElement;
+ body = eventDoc.body;
+
+ event.pageX = original.clientX +
+ ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) -
+ ( doc && doc.clientLeft || body && body.clientLeft || 0 );
+ event.pageY = original.clientY +
+ ( doc && doc.scrollTop || body && body.scrollTop || 0 ) -
+ ( doc && doc.clientTop || body && body.clientTop || 0 );
+ }
+
+ // Add which for click: 1 === left; 2 === middle; 3 === right
+ // Note: button is not normalized, so don't use it
+ if ( !event.which && button !== undefined ) {
+ event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) );
+ }
+
+ return event;
+ }
+ },
+
+ fix: function( event ) {
+ if ( event[ jQuery.expando ] ) {
+ return event;
+ }
+
+ // Create a writable copy of the event object and normalize some properties
+ var i, prop, copy,
+ type = event.type,
+ originalEvent = event,
+ fixHook = this.fixHooks[ type ];
+
+ if ( !fixHook ) {
+ this.fixHooks[ type ] = fixHook =
+ rmouseEvent.test( type ) ? this.mouseHooks :
+ rkeyEvent.test( type ) ? this.keyHooks :
+ {};
+ }
+ copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props;
+
+ event = new jQuery.Event( originalEvent );
+
+ i = copy.length;
+ while ( i-- ) {
+ prop = copy[ i ];
+ event[ prop ] = originalEvent[ prop ];
+ }
+
+ // Support: Cordova 2.5 (WebKit) (#13255)
+ // All events should have a target; Cordova deviceready doesn't
+ if ( !event.target ) {
+ event.target = document;
+ }
+
+ // Support: Safari 6.0+, Chrome<28
+ // Target should not be a text node (#504, #13143)
+ if ( event.target.nodeType === 3 ) {
+ event.target = event.target.parentNode;
+ }
+
+ return fixHook.filter ? fixHook.filter( event, originalEvent ) : event;
+ },
+
+ special: {
+ load: {
+
+ // Prevent triggered image.load events from bubbling to window.load
+ noBubble: true
+ },
+ focus: {
+
+ // Fire native event if possible so blur/focus sequence is correct
+ trigger: function() {
+ if ( this !== safeActiveElement() && this.focus ) {
+ this.focus();
+ return false;
+ }
+ },
+ delegateType: "focusin"
+ },
+ blur: {
+ trigger: function() {
+ if ( this === safeActiveElement() && this.blur ) {
+ this.blur();
+ return false;
+ }
+ },
+ delegateType: "focusout"
+ },
+ click: {
+
+ // For checkbox, fire native event so checked state will be right
+ trigger: function() {
+ if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) {
+ this.click();
+ return false;
+ }
+ },
+
+ // For cross-browser consistency, don't fire native .click() on links
+ _default: function( event ) {
+ return jQuery.nodeName( event.target, "a" );
+ }
+ },
+
+ beforeunload: {
+ postDispatch: function( event ) {
+
+ // Support: Firefox 20+
+ // Firefox doesn't alert if the returnValue field is not set.
+ if ( event.result !== undefined && event.originalEvent ) {
+ event.originalEvent.returnValue = event.result;
+ }
+ }
+ }
+ }
+};
+
+jQuery.removeEvent = function( elem, type, handle ) {
+
+ // This "if" is needed for plain objects
+ if ( elem.removeEventListener ) {
+ elem.removeEventListener( type, handle );
+ }
+};
+
+jQuery.Event = function( src, props ) {
+
+ // Allow instantiation without the 'new' keyword
+ if ( !( this instanceof jQuery.Event ) ) {
+ return new jQuery.Event( src, props );
+ }
+
+ // Event object
+ if ( src && src.type ) {
+ this.originalEvent = src;
+ this.type = src.type;
+
+ // Events bubbling up the document may have been marked as prevented
+ // by a handler lower down the tree; reflect the correct value.
+ this.isDefaultPrevented = src.defaultPrevented ||
+ src.defaultPrevented === undefined &&
+
+ // Support: Android<4.0
+ src.returnValue === false ?
+ returnTrue :
+ returnFalse;
+
+ // Event type
+ } else {
+ this.type = src;
+ }
+
+ // Put explicitly provided properties onto the event object
+ if ( props ) {
+ jQuery.extend( this, props );
+ }
+
+ // Create a timestamp if incoming event doesn't have one
+ this.timeStamp = src && src.timeStamp || jQuery.now();
+
+ // Mark it as fixed
+ this[ jQuery.expando ] = true;
+};
+
+// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding
+// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html
+jQuery.Event.prototype = {
+ constructor: jQuery.Event,
+ isDefaultPrevented: returnFalse,
+ isPropagationStopped: returnFalse,
+ isImmediatePropagationStopped: returnFalse,
+
+ preventDefault: function() {
+ var e = this.originalEvent;
+
+ this.isDefaultPrevented = returnTrue;
+
+ if ( e ) {
+ e.preventDefault();
+ }
+ },
+ stopPropagation: function() {
+ var e = this.originalEvent;
+
+ this.isPropagationStopped = returnTrue;
+
+ if ( e ) {
+ e.stopPropagation();
+ }
+ },
+ stopImmediatePropagation: function() {
+ var e = this.originalEvent;
+
+ this.isImmediatePropagationStopped = returnTrue;
+
+ if ( e ) {
+ e.stopImmediatePropagation();
+ }
+
+ this.stopPropagation();
+ }
+};
+
+// Create mouseenter/leave events using mouseover/out and event-time checks
+// so that event delegation works in jQuery.
+// Do the same for pointerenter/pointerleave and pointerover/pointerout
+//
+// Support: Safari 7 only
+// Safari sends mouseenter too often; see:
+// https://code.google.com/p/chromium/issues/detail?id=470258
+// for the description of the bug (it existed in older Chrome versions as well).
+jQuery.each( {
+ mouseenter: "mouseover",
+ mouseleave: "mouseout",
+ pointerenter: "pointerover",
+ pointerleave: "pointerout"
+}, function( orig, fix ) {
+ jQuery.event.special[ orig ] = {
+ delegateType: fix,
+ bindType: fix,
+
+ handle: function( event ) {
+ var ret,
+ target = this,
+ related = event.relatedTarget,
+ handleObj = event.handleObj;
+
+ // For mouseenter/leave call the handler if related is outside the target.
+ // NB: No relatedTarget if the mouse left/entered the browser window
+ if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) {
+ event.type = handleObj.origType;
+ ret = handleObj.handler.apply( this, arguments );
+ event.type = fix;
+ }
+ return ret;
+ }
+ };
+} );
+
+jQuery.fn.extend( {
+ on: function( types, selector, data, fn ) {
+ return on( this, types, selector, data, fn );
+ },
+ one: function( types, selector, data, fn ) {
+ return on( this, types, selector, data, fn, 1 );
+ },
+ off: function( types, selector, fn ) {
+ var handleObj, type;
+ if ( types && types.preventDefault && types.handleObj ) {
+
+ // ( event ) dispatched jQuery.Event
+ handleObj = types.handleObj;
+ jQuery( types.delegateTarget ).off(
+ handleObj.namespace ?
+ handleObj.origType + "." + handleObj.namespace :
+ handleObj.origType,
+ handleObj.selector,
+ handleObj.handler
+ );
+ return this;
+ }
+ if ( typeof types === "object" ) {
+
+ // ( types-object [, selector] )
+ for ( type in types ) {
+ this.off( type, selector, types[ type ] );
+ }
+ return this;
+ }
+ if ( selector === false || typeof selector === "function" ) {
+
+ // ( types [, fn] )
+ fn = selector;
+ selector = undefined;
+ }
+ if ( fn === false ) {
+ fn = returnFalse;
+ }
+ return this.each( function() {
+ jQuery.event.remove( this, types, fn, selector );
+ } );
+ }
+} );
+
+
+var
+ rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi,
+
+ // Support: IE 10-11, Edge 10240+
+ // In IE/Edge using regex groups here causes severe slowdowns.
+ // See https://connect.microsoft.com/IE/feedback/details/1736512/
+ rnoInnerhtml = /.");
+ }
+ };
+
+ _pageWindow.load(function() {
+ _pageLoaded = true;
+ });
+
+ function validateTransport(requestedTransport, connection) {
+ /// Validates the requested transport by cross checking it with the pre-defined signalR.transports
+ /// The designated transports that the user has specified.
+ /// The connection that will be using the requested transports. Used for logging purposes.
+ ///
+
+ if ($.isArray(requestedTransport)) {
+ // Go through transport array and remove an "invalid" tranports
+ for (var i = requestedTransport.length - 1; i >= 0; i--) {
+ var transport = requestedTransport[i];
+ if ($.type(requestedTransport) !== "object" && ($.type(transport) !== "string" || !signalR.transports[transport])) {
+ connection.log("Invalid transport: " + transport + ", removing it from the transports list.");
+ requestedTransport.splice(i, 1);
+ }
+ }
+
+ // Verify we still have transports left, if we dont then we have invalid transports
+ if (requestedTransport.length === 0) {
+ connection.log("No transports remain within the specified transport array.");
+ requestedTransport = null;
+ }
+ } else if ($.type(requestedTransport) !== "object" && !signalR.transports[requestedTransport] && requestedTransport !== "auto") {
+ connection.log("Invalid transport: " + requestedTransport.toString());
+ requestedTransport = null;
+ } else if (requestedTransport === "auto" && signalR._.ieVersion <= 8) {
+ // If we're doing an auto transport and we're IE8 then force longPolling, #1764
+ return ["longPolling"];
+
+ }
+
+ return requestedTransport;
+ }
+
+ function getDefaultPort(protocol) {
+ if (protocol === "http:") {
+ return 80;
+ } else if (protocol === "https:") {
+ return 443;
+ }
+ }
+
+ function addDefaultPort(protocol, url) {
+ // Remove ports from url. We have to check if there's a / or end of line
+ // following the port in order to avoid removing ports such as 8080.
+ if (url.match(/:\d+$/)) {
+ return url;
+ } else {
+ return url + ":" + getDefaultPort(protocol);
+ }
+ }
+
+ signalR.fn = signalR.prototype = {
+ init: function(url, qs, logging) {
+ this.url = url;
+ this.qs = qs;
+ this._ = {};
+ if (typeof (logging) === "boolean") {
+ this.logging = logging;
+ }
+ },
+
+ isCrossDomain: function(url, against) {
+ /// Checks if url is cross domain
+ /// The base URL
+ ///
+ /// An optional argument to compare the URL against, if not specified it will be set to window.location.
+ /// If specified it must contain a protocol and a host property.
+ ///
+ var link;
+
+ url = $.trim(url);
+ if (url.indexOf("http") !== 0) {
+ return false;
+ }
+
+ against = against || window.location;
+
+ // Create an anchor tag.
+ link = window.document.createElement("a");
+ link.href = url;
+
+ // When checking for cross domain we have to special case port 80 because the window.location will remove the
+ return link.protocol + addDefaultPort(link.protocol, link.host) !== against.protocol + addDefaultPort(against.protocol, against.host);
+ },
+
+ ajaxDataType: "json",
+
+ contentType: "application/json; charset=UTF-8",
+
+ logging: false,
+
+ state: signalR.connectionState.disconnected,
+
+ keepAliveData: {},
+
+ reconnectDelay: 2000,
+
+ disconnectTimeout: 30000, // This should be set by the server in response to the negotiate request (30s default)
+
+ keepAliveWarnAt: 2 / 3, // Warn user of slow connection if we breach the X% mark of the keep alive timeout
+
+ start: function(options, callback) {
+ /// Starts the connection
+ /// Options map
+ /// A callback function to execute when the connection has started
+ var connection = this,
+ config = {
+ waitForPageLoad: true,
+ transport: "auto",
+ jsonp: false
+ },
+ initialize,
+ deferred = connection._deferral || $.Deferred(), // Check to see if there is a pre-existing deferral that's being built on, if so we want to keep using it
+ parser = window.document.createElement("a");
+
+ if ($.type(options) === "function") {
+ // Support calling with single callback parameter
+ callback = options;
+ } else if ($.type(options) === "object") {
+ $.extend(config, options);
+ if ($.type(config.callback) === "function") {
+ callback = config.callback;
+ }
+ }
+
+ config.transport = validateTransport(config.transport, connection);
+
+ // If the transport is invalid throw an error and abort start
+ if (!config.transport) {
+ throw new Error("SignalR: Invalid transport(s) specified, aborting start.");
+ }
+
+ // Check to see if start is being called prior to page load
+ // If waitForPageLoad is true we then want to re-direct function call to the window load event
+ if (!_pageLoaded && config.waitForPageLoad === true) {
+ _pageWindow.load(function() {
+ connection._deferral = deferred;
+ connection.start(options, callback);
+ });
+ return deferred.promise();
+ }
+
+ configureStopReconnectingTimeout(connection);
+
+ if (changeState(connection,
+ signalR.connectionState.disconnected,
+ signalR.connectionState.connecting) === false) {
+ // Already started, just return
+ deferred.resolve(connection);
+ return deferred.promise();
+ }
+
+ // Resolve the full url
+ parser.href = connection.url;
+ if (!parser.protocol || parser.protocol === ":") {
+ connection.protocol = window.document.location.protocol;
+ connection.host = window.document.location.host;
+ connection.baseUrl = connection.protocol + "//" + connection.host;
+ } else {
+ connection.protocol = parser.protocol;
+ connection.host = parser.host;
+ connection.baseUrl = parser.protocol + "//" + parser.host;
+ }
+
+ // Set the websocket protocol
+ connection.wsProtocol = connection.protocol === "https:" ? "wss://" : "ws://";
+
+ // If jsonp with no/auto transport is specified, then set the transport to long polling
+ // since that is the only transport for which jsonp really makes sense.
+ // Some developers might actually choose to specify jsonp for same origin requests
+ // as demonstrated by Issue #623.
+ if (config.transport === "auto" && config.jsonp === true) {
+ config.transport = "longPolling";
+ }
+
+ if (this.isCrossDomain(connection.url)) {
+ connection.log("Auto detected cross domain url.");
+
+ if (config.transport === "auto") {
+ // Try webSockets and longPolling since SSE doesn't support CORS
+ // TODO: Support XDM with foreverFrame
+ config.transport = ["webSockets", "longPolling"];
+ }
+
+ // Determine if jsonp is the only choice for negotiation, ajaxSend and ajaxAbort.
+ // i.e. if the browser doesn't supports CORS
+ // If it is, ignore any preference to the contrary, and switch to jsonp.
+ if (!config.jsonp) {
+ config.jsonp = !$.support.cors;
+
+ if (config.jsonp) {
+ connection.log("Using jsonp because this browser doesn't support CORS");
+ }
+ }
+
+ connection.contentType = signalR._.defaultContentType;
+ }
+
+ connection.ajaxDataType = config.jsonp ? "jsonp" : "json";
+
+ $(connection).bind(events.onStart, function(e, data) {
+ if ($.type(callback) === "function") {
+ callback.call(connection);
+ }
+ deferred.resolve(connection);
+ });
+
+ initialize = function(transports, index) {
+ index = index || 0;
+ if (index >= transports.length) {
+ if (!connection.transport) {
+ // No transport initialized successfully
+ $(connection).triggerHandler(events.onError, ["SignalR: No transport could be initialized successfully. Try specifying a different transport or none at all for auto initialization."]);
+ deferred.reject("SignalR: No transport could be initialized successfully. Try specifying a different transport or none at all for auto initialization.");
+ // Stop the connection if it has connected and move it into the disconnected state
+ connection.stop();
+ }
+ return;
+ }
+
+ var transportName = transports[index],
+ transport = $.type(transportName) === "object" ? transportName : signalR.transports[transportName];
+
+ if (transportName.indexOf("_") === 0) {
+ // Private member
+ initialize(transports, index + 1);
+ return;
+ }
+
+ transport.start(connection, function() { // success
+ if (transport.supportsKeepAlive && connection.keepAliveData.activated) {
+ signalR.transports._logic.monitorKeepAlive(connection);
+ }
+
+ connection.transport = transport;
+
+ changeState(connection,
+ signalR.connectionState.connecting,
+ signalR.connectionState.connected);
+
+ $(connection).triggerHandler(events.onStart);
+
+ _pageWindow.unload(function() { // failure
+ connection.stop(false /* async */ );
+ });
+ }, function() {
+ initialize(transports, index + 1);
+ });
+ };
+
+ var url = connection.url + "/negotiate";
+
+ url = signalR.transports._logic.addQs(url, connection);
+
+ connection.log("Negotiating with '" + url + "'.");
+ $.ajax({
+ url: url,
+ global: true,
+ cache: false,
+ type: "GET",
+ contentType: connection.contentType,
+ data: {},
+ dataType: connection.ajaxDataType,
+ error: function(error) {
+ $(connection).triggerHandler(events.onError, [error.responseText]);
+ deferred.reject("SignalR: Error during negotiation request: " + error.responseText);
+ // Stop the connection if negotiate failed
+ connection.stop();
+ },
+ success: function(res) {
+ var keepAliveData = connection.keepAliveData;
+
+ connection.appRelativeUrl = res.Url;
+ connection.id = res.ConnectionId;
+ connection.token = res.ConnectionToken;
+ connection.webSocketServerUrl = res.WebSocketServerUrl;
+
+ // Once the server has labeled the PersistentConnection as Disconnected, we should stop attempting to reconnect
+ // after res.DisconnectTimeout seconds.
+ connection.disconnectTimeout = res.DisconnectTimeout * 1000; // in ms
+
+
+ // If we have a keep alive
+ if (res.KeepAliveTimeout) {
+ // Register the keep alive data as activated
+ keepAliveData.activated = true;
+
+ // Timeout to designate when to force the connection into reconnecting converted to milliseconds
+ keepAliveData.timeout = res.KeepAliveTimeout * 1000;
+
+ // Timeout to designate when to warn the developer that the connection may be dead or is not responding.
+ keepAliveData.timeoutWarning = keepAliveData.timeout * connection.keepAliveWarnAt;
+
+ // Instantiate the frequency in which we check the keep alive. It must be short in order to not miss/pick up any changes
+ keepAliveData.checkInterval = (keepAliveData.timeout - keepAliveData.timeoutWarning) / 3;
+ } else {
+ keepAliveData.activated = false;
+ }
+
+ if (!res.ProtocolVersion || res.ProtocolVersion !== "1.2") {
+ $(connection).triggerHandler(events.onError, ["You are using a version of the client that isn't compatible with the server. Client version 1.2, server version " + res.ProtocolVersion + "."]);
+ deferred.reject("You are using a version of the client that isn't compatible with the server. Client version 1.2, server version " + res.ProtocolVersion + ".");
+ return;
+ }
+
+ $(connection).triggerHandler(events.onStarting);
+
+ var transports = [],
+ supportedTransports = [];
+
+ $.each(signalR.transports, function(key) {
+ if (key === "webSockets" && !res.TryWebSockets) {
+ // Server said don't even try WebSockets, but keep processing the loop
+ return true;
+ }
+ supportedTransports.push(key);
+ });
+
+ if ($.isArray(config.transport)) {
+ // ordered list provided
+ $.each(config.transport, function() {
+ var transport = this;
+ if ($.type(transport) === "object" || ($.type(transport) === "string" && $.inArray("" + transport, supportedTransports) >= 0)) {
+ transports.push($.type(transport) === "string" ? "" + transport : transport);
+ }
+ });
+ } else if ($.type(config.transport) === "object" ||
+ $.inArray(config.transport, supportedTransports) >= 0) {
+ // specific transport provided, as object or a named transport, e.g. "longPolling"
+ transports.push(config.transport);
+ } else { // default "auto"
+ transports = supportedTransports;
+ }
+ initialize(transports);
+ }
+ });
+
+ return deferred.promise();
+ },
+
+ starting: function(callback) {
+ /// Adds a callback that will be invoked before anything is sent over the connection
+ /// A callback function to execute before each time data is sent on the connection
+ ///
+ var connection = this;
+ $(connection).bind(events.onStarting, function(e, data) {
+ callback.call(connection);
+ });
+ return connection;
+ },
+
+ send: function(data) {
+ /// Sends data over the connection
+ /// The data to send over the connection
+ ///
+ var connection = this;
+
+ if (connection.state === signalR.connectionState.disconnected) {
+ // Connection hasn't been started yet
+ throw new Error("SignalR: Connection must be started before data can be sent. Call .start() before .send()");
+ }
+
+ if (connection.state === signalR.connectionState.connecting) {
+ // Connection hasn't been started yet
+ throw new Error("SignalR: Connection has not been fully initialized. Use .start().done() or .start().fail() to run logic after the connection has started.");
+ }
+
+ connection.transport.send(connection, data);
+ // REVIEW: Should we return deferred here?
+ return connection;
+ },
+
+ received: function(callback) {
+ /// Adds a callback that will be invoked after anything is received over the connection
+ /// A callback function to execute when any data is received on the connection
+ ///
+ var connection = this;
+ $(connection).bind(events.onReceived, function(e, data) {
+ callback.call(connection, data);
+ });
+ return connection;
+ },
+
+ stateChanged: function(callback) {
+ /// Adds a callback that will be invoked when the connection state changes
+ /// A callback function to execute when the connection state changes
+ ///
+ var connection = this;
+ $(connection).bind(events.onStateChanged, function(e, data) {
+ callback.call(connection, data);
+ });
+ return connection;
+ },
+
+ error: function(callback) {
+ /// Adds a callback that will be invoked after an error occurs with the connection
+ /// A callback function to execute when an error occurs on the connection
+ ///
+ var connection = this;
+ $(connection).bind(events.onError, function(e, data) {
+ callback.call(connection, data);
+ });
+ return connection;
+ },
+
+ disconnected: function(callback) {
+ /// Adds a callback that will be invoked when the client disconnects
+ /// A callback function to execute when the connection is broken
+ ///
+ var connection = this;
+ $(connection).bind(events.onDisconnect, function(e, data) {
+ callback.call(connection);
+ });
+ return connection;
+ },
+
+ connectionSlow: function(callback) {
+ /// Adds a callback that will be invoked when the client detects a slow connection
+ /// A callback function to execute when the connection is slow
+ ///
+ var connection = this;
+ $(connection).bind(events.onConnectionSlow, function(e, data) {
+ callback.call(connection);
+ });
+
+ return connection;
+ },
+
+ reconnecting: function(callback) {
+ /// Adds a callback that will be invoked when the underlying transport begins reconnecting
+ /// A callback function to execute when the connection enters a reconnecting state
+ ///
+ var connection = this;
+ $(connection).bind(events.onReconnecting, function(e, data) {
+ callback.call(connection);
+ });
+ return connection;
+ },
+
+ reconnected: function(callback) {
+ /// Adds a callback that will be invoked when the underlying transport reconnects
+ /// A callback function to execute when the connection is restored
+ ///
+ var connection = this;
+ $(connection).bind(events.onReconnect, function(e, data) {
+ callback.call(connection);
+ });
+ return connection;
+ },
+
+ stop: function(async, notifyServer) {
+ /// Stops listening
+ /// Whether or not to asynchronously abort the connection
+ /// Whether we want to notify the server that we are aborting the connection
+ ///
+ var connection = this;
+
+ if (connection.state === signalR.connectionState.disconnected) {
+ return;
+ }
+
+ try {
+ if (connection.transport) {
+ if (notifyServer !== false) {
+ connection.transport.abort(connection, async);
+ }
+
+ if (connection.transport.supportsKeepAlive && connection.keepAliveData.activated) {
+ signalR.transports._logic.stopMonitoringKeepAlive(connection);
+ }
+
+ connection.transport.stop(connection);
+ connection.transport = null;
+ }
+
+ // Trigger the disconnect event
+ $(connection).triggerHandler(events.onDisconnect);
+
+ delete connection.messageId;
+ delete connection.groupsToken;
+
+ // Remove the ID and the deferral on stop, this is to ensure that if a connection is restarted it takes on a new id/deferral.
+ delete connection.id;
+ delete connection._deferral;
+ } finally {
+ changeState(connection, connection.state, signalR.connectionState.disconnected);
+ }
+
+ return connection;
+ },
+
+ log: function(msg) {
+ log(msg, this.logging);
+ }
+ };
+
+ signalR.fn.init.prototype = signalR.fn;
+
+ signalR.noConflict = function() {
+ /// Reinstates the original value of $.connection and returns the signalR object for manual assignment
+ ///
+ if ($.connection === signalR) {
+ $.connection = _connection;
+ }
+ return signalR;
+ };
+
+ if ($.connection) {
+ _connection = $.connection;
+ }
+
+ $.connection = $.signalR = signalR;
+}(window.jQuery, window));
+/* jquery.signalR.transports.common.js */
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information.
+
+/*global window:false */
+///
+
+( function($, window) {
+ "use strict";
+
+ var signalR = $.signalR,
+ events = $.signalR.events,
+ changeState = $.signalR.changeState;
+
+ signalR.transports = {};
+
+ function checkIfAlive(connection) {
+ var keepAliveData = connection.keepAliveData,
+ diff,
+ timeElapsed;
+
+ // Only check if we're connected
+ if (connection.state === signalR.connectionState.connected) {
+ diff = new Date();
+
+ diff.setTime(diff - keepAliveData.lastKeepAlive);
+ timeElapsed = diff.getTime();
+
+ // Check if the keep alive has completely timed out
+ if (timeElapsed >= keepAliveData.timeout) {
+ connection.log("Keep alive timed out. Notifying transport that connection has been lost.");
+
+ // Notify transport that the connection has been lost
+ connection.transport.lostConnection(connection);
+ } else if (timeElapsed >= keepAliveData.timeoutWarning) {
+ // This is to assure that the user only gets a single warning
+ if (!keepAliveData.userNotified) {
+ connection.log("Keep alive has been missed, connection may be dead/slow.");
+ $(connection).triggerHandler(events.onConnectionSlow);
+ keepAliveData.userNotified = true;
+ }
+ } else {
+ keepAliveData.userNotified = false;
+ }
+ }
+
+ // Verify we're monitoring the keep alive
+ // We don't want this as a part of the inner if statement above because we want keep alives to continue to be checked
+ // in the event that the server comes back online (if it goes offline).
+ if (keepAliveData.monitoring) {
+ window.setTimeout(function() {
+ checkIfAlive(connection);
+ }, keepAliveData.checkInterval);
+ }
+ }
+
+ function isConnectedOrReconnecting(connection) {
+ return connection.state === signalR.connectionState.connected ||
+ connection.state === signalR.connectionState.reconnecting;
+ }
+
+ signalR.transports._logic = {
+ pingServer: function(connection, transport) {
+ /// Pings the server
+ /// Connection associated with the server ping
+ ///
+ var baseUrl = transport === "webSockets" ? "" : connection.baseUrl,
+ url = baseUrl + connection.appRelativeUrl + "/ping",
+ deferral = $.Deferred();
+
+ url = this.addQs(url, connection);
+
+ $.ajax({
+ url: url,
+ global: true,
+ cache: false,
+ type: "GET",
+ contentType: connection.contentType,
+ data: {},
+ dataType: connection.ajaxDataType,
+ success: function(data) {
+ if (data.Response === "pong") {
+ deferral.resolve();
+ } else {
+ deferral.reject("SignalR: Invalid ping response when pinging server: " + (data.responseText || data.statusText));
+ }
+ },
+ error: function(data) {
+ deferral.reject("SignalR: Error pinging server: " + (data.responseText || data.statusText));
+ }
+ });
+
+ return deferral.promise();
+ },
+
+ addQs: function(url, connection) {
+ var appender = url.indexOf("?") !== -1 ? "&" : "?",
+ firstChar;
+
+ if (!connection.qs) {
+ return url;
+ }
+
+ if (typeof (connection.qs) === "object") {
+ return url + appender + $.param(connection.qs);
+ }
+
+ if (typeof (connection.qs) === "string") {
+ firstChar = connection.qs.charAt(0);
+
+ if (firstChar === "?" || firstChar === "&") {
+ appender = "";
+ }
+
+ return url + appender + connection.qs;
+ }
+
+ throw new Error("Connections query string property must be either a string or object.");
+ },
+
+ getUrl: function(connection, transport, reconnecting, poll) {
+ /// Gets the url for making a GET based connect request
+ var baseUrl = transport === "webSockets" ? "" : connection.baseUrl,
+ url = baseUrl + connection.appRelativeUrl,
+ qs = "transport=" + transport + "&connectionToken=" + window.encodeURIComponent(connection.token);
+
+ if (connection.data) {
+ qs += "&connectionData=" + window.encodeURIComponent(connection.data);
+ }
+
+ if (connection.groupsToken) {
+ qs += "&groupsToken=" + window.encodeURIComponent(connection.groupsToken);
+ }
+
+ if (!reconnecting) {
+ url += "/connect";
+ } else {
+ if (poll) {
+ // longPolling transport specific
+ url += "/poll";
+ } else {
+ url += "/reconnect";
+ }
+
+ if (connection.messageId) {
+ qs += "&messageId=" + window.encodeURIComponent(connection.messageId);
+ }
+ }
+ url += "?" + qs;
+ url = this.addQs(url, connection);
+ url += "&tid=" + Math.floor(Math.random() * 11);
+ return url;
+ },
+
+ maximizePersistentResponse: function(minPersistentResponse) {
+ return {
+ MessageId: minPersistentResponse.C,
+ Messages: minPersistentResponse.M,
+ Disconnect: typeof (minPersistentResponse.D) !== "undefined" ? true : false,
+ TimedOut: typeof (minPersistentResponse.T) !== "undefined" ? true : false,
+ LongPollDelay: minPersistentResponse.L,
+ GroupsToken: minPersistentResponse.G
+ };
+ },
+
+ updateGroups: function(connection, groupsToken) {
+ if (groupsToken) {
+ connection.groupsToken = groupsToken;
+ }
+ },
+
+ ajaxSend: function(connection, data) {
+ var url = connection.url + "/send" + "?transport=" + connection.transport.name + "&connectionToken=" + window.encodeURIComponent(connection.token);
+ url = this.addQs(url, connection);
+ return $.ajax({
+ url: url,
+ global: true,
+ type: connection.ajaxDataType === "jsonp" ? "GET" : "POST",
+ contentType: signalR._.defaultContentType,
+ dataType: connection.ajaxDataType,
+ data: {
+ data: data
+ },
+ success: function(result) {
+ if (result) {
+ $(connection).triggerHandler(events.onReceived, [result]);
+ }
+ },
+ error: function(errData, textStatus) {
+ if (textStatus === "abort" || textStatus === "parsererror") {
+ // The parsererror happens for sends that don't return any data, and hence
+ // don't write the jsonp callback to the response. This is harder to fix on the server
+ // so just hack around it on the client for now.
+ return;
+ }
+ $(connection).triggerHandler(events.onError, [errData, data]);
+ }
+ });
+ },
+
+ ajaxAbort: function(connection, async) {
+ if (typeof (connection.transport) === "undefined") {
+ return;
+ }
+
+ // Async by default unless explicitly overidden
+ async = typeof async === "undefined" ? true : async;
+
+ var url = connection.url + "/abort" + "?transport=" + connection.transport.name + "&connectionToken=" + window.encodeURIComponent(connection.token);
+ url = this.addQs(url, connection);
+ $.ajax({
+ url: url,
+ async: async,
+ timeout: 1000,
+ global: true,
+ type: "POST",
+ contentType: connection.contentType,
+ dataType: connection.ajaxDataType,
+ data: {}
+ });
+
+ connection.log("Fired ajax abort async = " + async);
+ },
+
+ processMessages: function(connection, minData) {
+ var data;
+ // Transport can be null if we've just closed the connection
+ if (connection.transport) {
+ var $connection = $(connection);
+
+ // If our transport supports keep alive then we need to update the last keep alive time stamp.
+ // Very rarely the transport can be null.
+ if (connection.transport.supportsKeepAlive && connection.keepAliveData.activated) {
+ this.updateKeepAlive(connection);
+ }
+
+ if (!minData) {
+ return;
+ }
+
+ data = this.maximizePersistentResponse(minData);
+
+ if (data.Disconnect) {
+ connection.log("Disconnect command received from server");
+
+ // Disconnected by the server
+ connection.stop(false, false);
+ return;
+ }
+
+ this.updateGroups(connection, data.GroupsToken);
+
+ if (data.Messages) {
+ $.each(data.Messages, function(index, message) {
+ $connection.triggerHandler(events.onReceived, [message]);
+ });
+ }
+
+ if (data.MessageId) {
+ connection.messageId = data.MessageId;
+ }
+ }
+ },
+
+ monitorKeepAlive: function(connection) {
+ var keepAliveData = connection.keepAliveData,
+ that = this;
+
+ // If we haven't initiated the keep alive timeouts then we need to
+ if (!keepAliveData.monitoring) {
+ keepAliveData.monitoring = true;
+
+ // Initialize the keep alive time stamp ping
+ that.updateKeepAlive(connection);
+
+ // Save the function so we can unbind it on stop
+ connection.keepAliveData.reconnectKeepAliveUpdate = function() {
+ that.updateKeepAlive(connection);
+ };
+
+ // Update Keep alive on reconnect
+ $(connection).bind(events.onReconnect, connection.keepAliveData.reconnectKeepAliveUpdate);
+
+ connection.log("Now monitoring keep alive with a warning timeout of " + keepAliveData.timeoutWarning + " and a connection lost timeout of " + keepAliveData.timeout);
+ // Start the monitoring of the keep alive
+ checkIfAlive(connection);
+ } else {
+ connection.log("Tried to monitor keep alive but it's already being monitored");
+ }
+ },
+
+ stopMonitoringKeepAlive: function(connection) {
+ var keepAliveData = connection.keepAliveData;
+
+ // Only attempt to stop the keep alive monitoring if its being monitored
+ if (keepAliveData.monitoring) {
+ // Stop monitoring
+ keepAliveData.monitoring = false;
+
+ // Remove the updateKeepAlive function from the reconnect event
+ $(connection).unbind(events.onReconnect, connection.keepAliveData.reconnectKeepAliveUpdate);
+
+ // Clear all the keep alive data
+ connection.keepAliveData = {};
+ connection.log("Stopping the monitoring of the keep alive");
+ }
+ },
+
+ updateKeepAlive: function(connection) {
+ connection.keepAliveData.lastKeepAlive = new Date();
+ },
+
+ ensureReconnectingState: function(connection) {
+ if (changeState(connection,
+ signalR.connectionState.connected,
+ signalR.connectionState.reconnecting) === true) {
+ $(connection).triggerHandler(events.onReconnecting);
+ }
+ return connection.state === signalR.connectionState.reconnecting;
+ },
+
+ clearReconnectTimeout: function(connection) {
+ if (connection && connection._.reconnectTimeout) {
+ window.clearTimeout(connection._.reconnectTimeout);
+ delete connection._.reconnectTimeout;
+ }
+ },
+
+ reconnect: function(connection, transportName) {
+ var transport = signalR.transports[transportName],
+ that = this;
+
+ // We should only set a reconnectTimeout if we are currently connected
+ // and a reconnectTimeout isn't already set.
+ if (isConnectedOrReconnecting(connection) && !connection._.reconnectTimeout) {
+
+ connection._.reconnectTimeout = window.setTimeout(function() {
+ transport.stop(connection);
+
+ if (that.ensureReconnectingState(connection)) {
+ connection.log(transportName + " reconnecting");
+ transport.start(connection);
+ }
+ }, connection.reconnectDelay);
+ }
+ },
+
+ foreverFrame: {
+ count: 0,
+ connections: {}
+ }
+ };
+}(window.jQuery, window));
+/* jquery.signalR.transports.webSockets.js */
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information.
+
+/*global window:false */
+///
+
+( function($, window) {
+ "use strict";
+
+ var signalR = $.signalR,
+ events = $.signalR.events,
+ changeState = $.signalR.changeState,
+ transportLogic = signalR.transports._logic;
+
+ signalR.transports.webSockets = {
+ name: "webSockets",
+
+ supportsKeepAlive: true,
+
+ send: function(connection, data) {
+ connection.socket.send(data);
+ },
+
+ start: function(connection, onSuccess, onFailed) {
+ var url,
+ opened = false,
+ that = this,
+ reconnecting = !onSuccess,
+ $connection = $(connection);
+
+ if (!window.WebSocket) {
+ onFailed();
+ return;
+ }
+
+ if (!connection.socket) {
+ if (connection.webSocketServerUrl) {
+ url = connection.webSocketServerUrl;
+ } else {
+ url = connection.wsProtocol + connection.host;
+ }
+
+ url += transportLogic.getUrl(connection, this.name, reconnecting);
+
+ connection.log("Connecting to websocket endpoint '" + url + "'");
+ connection.socket = new window.WebSocket(url);
+ connection.socket.onopen = function() {
+ opened = true;
+ connection.log("Websocket opened");
+
+ transportLogic.clearReconnectTimeout(connection);
+
+ if (onSuccess) {
+ onSuccess();
+ } else if (changeState(connection,
+ signalR.connectionState.reconnecting,
+ signalR.connectionState.connected) === true) {
+ $connection.triggerHandler(events.onReconnect);
+ }
+ };
+
+ connection.socket.onclose = function(event) {
+ // Only handle a socket close if the close is from the current socket.
+ // Sometimes on disconnect the server will push down an onclose event
+ // to an expired socket.
+ if (this === connection.socket) {
+ if (!opened) {
+ if (onFailed) {
+ onFailed();
+ } else if (reconnecting) {
+ that.reconnect(connection);
+ }
+ return;
+ } else if (typeof event.wasClean !== "undefined" && event.wasClean === false) {
+ // Ideally this would use the websocket.onerror handler (rather than checking wasClean in onclose) but
+ // I found in some circumstances Chrome won't call onerror. This implementation seems to work on all browsers.
+ $(connection).triggerHandler(events.onError, [event.reason]);
+ connection.log("Unclean disconnect from websocket." + event.reason);
+ } else {
+ connection.log("Websocket closed");
+ }
+
+ that.reconnect(connection);
+ }
+ };
+
+ connection.socket.onmessage = function(event) {
+ var data = window.JSON.parse(event.data),
+ $connection = $(connection);
+
+ if (data) {
+ // data.M is PersistentResponse.Messages
+ if ($.isEmptyObject(data) || data.M) {
+ transportLogic.processMessages(connection, data);
+ } else {
+ // For websockets we need to trigger onReceived
+ // for callbacks to outgoing hub calls.
+ $connection.triggerHandler(events.onReceived, [data]);
+ }
+ }
+ };
+ }
+ },
+
+ reconnect: function(connection) {
+ transportLogic.reconnect(connection, this.name);
+ },
+
+ lostConnection: function(connection) {
+ this.reconnect(connection);
+ },
+
+ stop: function(connection) {
+ // Don't trigger a reconnect after stopping
+ transportLogic.clearReconnectTimeout(connection);
+
+ if (connection.socket !== null) {
+ connection.log("Closing the Websocket");
+ connection.socket.close();
+ connection.socket = null;
+ }
+ },
+
+ abort: function(connection) {}
+ };
+}(window.jQuery, window));
+/* jquery.signalR.transports.serverSentEvents.js */
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information.
+
+/*global window:false */
+///
+
+( function($, window) {
+ "use strict";
+
+ var signalR = $.signalR,
+ events = $.signalR.events,
+ changeState = $.signalR.changeState,
+ transportLogic = signalR.transports._logic;
+
+ signalR.transports.serverSentEvents = {
+ name: "serverSentEvents",
+
+ supportsKeepAlive: true,
+
+ timeOut: 3000,
+
+ start: function(connection, onSuccess, onFailed) {
+ var that = this,
+ opened = false,
+ $connection = $(connection),
+ reconnecting = !onSuccess,
+ url,
+ connectTimeOut;
+
+ if (connection.eventSource) {
+ connection.log("The connection already has an event source. Stopping it.");
+ connection.stop();
+ }
+
+ if (!window.EventSource) {
+ if (onFailed) {
+ connection.log("This browser doesn't support SSE.");
+ onFailed();
+ }
+ return;
+ }
+
+ url = transportLogic.getUrl(connection, this.name, reconnecting);
+
+ try {
+ connection.log("Attempting to connect to SSE endpoint '" + url + "'");
+ connection.eventSource = new window.EventSource(url);
+ } catch (e) {
+ connection.log("EventSource failed trying to connect with error " + e.Message);
+ if (onFailed) {
+ // The connection failed, call the failed callback
+ onFailed();
+ } else {
+ $connection.triggerHandler(events.onError, [e]);
+ if (reconnecting) {
+ // If we were reconnecting, rather than doing initial connect, then try reconnect again
+ that.reconnect(connection);
+ }
+ }
+ return;
+ }
+
+ // After connecting, if after the specified timeout there's no response stop the connection
+ // and raise on failed
+ connectTimeOut = window.setTimeout(function() {
+ if (opened === false) {
+ connection.log("EventSource timed out trying to connect");
+ connection.log("EventSource readyState: " + connection.eventSource.readyState);
+
+ if (!reconnecting) {
+ that.stop(connection);
+ }
+
+ if (reconnecting) {
+ // If we're reconnecting and the event source is attempting to connect,
+ // don't keep retrying. This causes duplicate connections to spawn.
+ if (connection.eventSource.readyState !== window.EventSource.CONNECTING &&
+ connection.eventSource.readyState !== window.EventSource.OPEN) {
+ // If we were reconnecting, rather than doing initial connect, then try reconnect again
+ that.reconnect(connection);
+ }
+ } else if (onFailed) {
+ onFailed();
+ }
+ }
+ },
+ that.timeOut);
+
+ connection.eventSource.addEventListener("open", function(e) {
+ connection.log("EventSource connected");
+
+ if (connectTimeOut) {
+ window.clearTimeout(connectTimeOut);
+ }
+
+ transportLogic.clearReconnectTimeout(connection);
+
+ if (opened === false) {
+ opened = true;
+
+ if (onSuccess) {
+ onSuccess();
+ } else if (changeState(connection,
+ signalR.connectionState.reconnecting,
+ signalR.connectionState.connected) === true) {
+ // If there's no onSuccess handler we assume this is a reconnect
+ $connection.triggerHandler(events.onReconnect);
+ }
+ }
+ }, false);
+
+ connection.eventSource.addEventListener("message", function(e) {
+ // process messages
+ if (e.data === "initialized") {
+ return;
+ }
+
+ transportLogic.processMessages(connection, window.JSON.parse(e.data));
+ }, false);
+
+ connection.eventSource.addEventListener("error", function(e) {
+ // Only handle an error if the error is from the current Event Source.
+ // Sometimes on disconnect the server will push down an error event
+ // to an expired Event Source.
+ if (this === connection.eventSource) {
+ if (!opened) {
+ if (onFailed) {
+ onFailed();
+ }
+
+ return;
+ }
+
+ connection.log("EventSource readyState: " + connection.eventSource.readyState);
+
+ if (e.eventPhase === window.EventSource.CLOSED) {
+ // We don't use the EventSource's native reconnect function as it
+ // doesn't allow us to change the URL when reconnecting. We need
+ // to change the URL to not include the /connect suffix, and pass
+ // the last message id we received.
+ connection.log("EventSource reconnecting due to the server connection ending");
+ that.reconnect(connection);
+ } else {
+ // connection error
+ connection.log("EventSource error");
+ $connection.triggerHandler(events.onError);
+ }
+ }
+ }, false);
+ },
+
+ reconnect: function(connection) {
+ transportLogic.reconnect(connection, this.name);
+ },
+
+ lostConnection: function(connection) {
+ this.reconnect(connection);
+ },
+
+ send: function(connection, data) {
+ transportLogic.ajaxSend(connection, data);
+ },
+
+ stop: function(connection) {
+ // Don't trigger a reconnect after stopping
+ transportLogic.clearReconnectTimeout(connection);
+
+ if (connection && connection.eventSource) {
+ connection.log("EventSource calling close()");
+ connection.eventSource.close();
+ connection.eventSource = null;
+ delete connection.eventSource;
+ }
+ },
+
+ abort: function(connection, async) {
+ transportLogic.ajaxAbort(connection, async);
+ }
+ };
+}(window.jQuery, window));
+/* jquery.signalR.transports.foreverFrame.js */
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information.
+
+/*global window:false */
+///
+
+( function($, window) {
+ "use strict";
+
+ var signalR = $.signalR,
+ events = $.signalR.events,
+ changeState = $.signalR.changeState,
+ transportLogic = signalR.transports._logic,
+ // Used to prevent infinite loading icon spins in older versions of ie
+ // We build this object inside a closure so we don't pollute the rest of
+ // the foreverFrame transport with unnecessary functions/utilities.
+ loadPreventer = (function() {
+ var loadingFixIntervalId = null,
+ loadingFixInterval = 1000,
+ attachedTo = 0;
+
+ return {
+ prevent: function() {
+ // Prevent additional iframe removal procedures from newer browsers
+ if (signalR._.ieVersion <= 8) {
+ // We only ever want to set the interval one time, so on the first attachedTo
+ if (attachedTo === 0) {
+ // Create and destroy iframe every 3 seconds to prevent loading icon, super hacky
+ loadingFixIntervalId = window.setInterval(function() {
+ var tempFrame = $("");
+
+ $("body").append(tempFrame);
+ tempFrame.remove();
+ tempFrame = null;
+ }, loadingFixInterval);
+ }
+
+ attachedTo++;
+ }
+ },
+ cancel: function() {
+ // Only clear the interval if there's only one more object that the loadPreventer is attachedTo
+ if (attachedTo === 1) {
+ window.clearInterval(loadingFixIntervalId);
+ }
+
+ if (attachedTo > 0) {
+ attachedTo--;
+ }
+ }
+ };
+ })();
+
+ signalR.transports.foreverFrame = {
+ name: "foreverFrame",
+
+ supportsKeepAlive: true,
+
+ timeOut: 3000,
+
+ start: function(connection, onSuccess, onFailed) {
+ var that = this,
+ frameId = (transportLogic.foreverFrame.count += 1),
+ url,
+ frame = $("");
+
+ if (window.EventSource) {
+ // If the browser supports SSE, don't use Forever Frame
+ if (onFailed) {
+ connection.log("This browser supports SSE, skipping Forever Frame.");
+ onFailed();
+ }
+ return;
+ }
+
+ // Start preventing loading icon
+ // This will only perform work if the loadPreventer is not attached to another connection.
+ loadPreventer.prevent();
+
+ // Build the url
+ url = transportLogic.getUrl(connection, this.name);
+ url += "&frameId=" + frameId;
+
+ // Set body prior to setting URL to avoid caching issues.
+ $("body").append(frame);
+
+ frame.prop("src", url);
+ transportLogic.foreverFrame.connections[frameId] = connection;
+
+ connection.log("Binding to iframe's readystatechange event.");
+ frame.bind("readystatechange", function() {
+ if ($.inArray(this.readyState, ["loaded", "complete"]) >= 0) {
+ connection.log("Forever frame iframe readyState changed to " + this.readyState + ", reconnecting");
+
+ that.reconnect(connection);
+ }
+ });
+
+ connection.frame = frame[0];
+ connection.frameId = frameId;
+
+ if (onSuccess) {
+ connection.onSuccess = onSuccess;
+ }
+
+ // After connecting, if after the specified timeout there's no response stop the connection
+ // and raise on failed
+ window.setTimeout(function() {
+ if (connection.onSuccess) {
+ connection.log("Failed to connect using forever frame source, it timed out after " + that.timeOut + "ms.");
+ that.stop(connection);
+
+ if (onFailed) {
+ onFailed();
+ }
+ }
+ }, that.timeOut);
+ },
+
+ reconnect: function(connection) {
+ var that = this;
+ window.setTimeout(function() {
+ if (connection.frame && transportLogic.ensureReconnectingState(connection)) {
+ var frame = connection.frame,
+ src = transportLogic.getUrl(connection, that.name, true) + "&frameId=" + connection.frameId;
+ connection.log("Updating iframe src to '" + src + "'.");
+ frame.src = src;
+ }
+ }, connection.reconnectDelay);
+ },
+
+ lostConnection: function(connection) {
+ this.reconnect(connection);
+ },
+
+ send: function(connection, data) {
+ transportLogic.ajaxSend(connection, data);
+ },
+
+ receive: function(connection, data) {
+ var cw;
+
+ transportLogic.processMessages(connection, data);
+ // Delete the script & div elements
+ connection.frameMessageCount = (connection.frameMessageCount || 0) + 1;
+ if (connection.frameMessageCount > 50) {
+ connection.frameMessageCount = 0;
+ cw = connection.frame.contentWindow || connection.frame.contentDocument;
+ if (cw && cw.document) {
+ $("body", cw.document).empty();
+ }
+ }
+ },
+
+ stop: function(connection) {
+ var cw = null;
+
+ // Stop attempting to prevent loading icon
+ loadPreventer.cancel();
+
+ if (connection.frame) {
+ if (connection.frame.stop) {
+ connection.frame.stop();
+ } else {
+ try {
+ cw = connection.frame.contentWindow || connection.frame.contentDocument;
+ if (cw.document && cw.document.execCommand) {
+ cw.document.execCommand("Stop");
+ }
+ } catch (e) {
+ connection.log("SignalR: Error occured when stopping foreverFrame transport. Message = " + e.message);
+ }
+ }
+ $(connection.frame).remove();
+ delete transportLogic.foreverFrame.connections[connection.frameId]
+ ;
+ connection.frame = null;
+ connection.frameId = null;
+ delete connection.frame;
+ delete connection.frameId;
+ connection.log("Stopping forever frame");
+ }
+ },
+
+ abort: function(connection, async) {
+ transportLogic.ajaxAbort(connection, async);
+ },
+
+ getConnection: function(id) {
+ return transportLogic.foreverFrame.connections[id];
+ },
+
+ started: function(connection) {
+ if (connection.onSuccess) {
+ connection.onSuccess();
+ connection.onSuccess = null;
+ delete connection.onSuccess;
+ } else if (changeState(connection,
+ signalR.connectionState.reconnecting,
+ signalR.connectionState.connected) === true) {
+ // If there's no onSuccess handler we assume this is a reconnect
+ $(connection).triggerHandler(events.onReconnect);
+ }
+ }
+ };
+}(window.jQuery, window));
+/* jquery.signalR.transports.longPolling.js */
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information.
+
+/*global window:false */
+///
+
+( function($, window) {
+ "use strict";
+
+ var signalR = $.signalR,
+ events = $.signalR.events,
+ changeState = $.signalR.changeState,
+ isDisconnecting = $.signalR.isDisconnecting,
+ transportLogic = signalR.transports._logic;
+
+ signalR.transports.longPolling = {
+ name: "longPolling",
+
+ supportsKeepAlive: false,
+
+ reconnectDelay: 3000,
+
+ init: function(connection, onComplete) {
+ /// Pings the server to ensure availability
+ /// Connection associated with the server ping
+ /// Callback to call once initialization has completed
+
+ var that = this,
+ pingLoop,
+ // pingFail is used to loop the re-ping behavior. When we fail we want to re-try.
+ pingFail = function(reason) {
+ if (isDisconnecting(connection) === false) {
+ connection.log("SignalR: Server ping failed because '" + reason + "', re-trying ping.");
+ window.setTimeout(pingLoop, that.reconnectDelay);
+ }
+ };
+
+ connection.log("SignalR: Initializing long polling connection with server.");
+ pingLoop = function() {
+ // Ping the server, on successful ping call the onComplete method, otherwise if we fail call the pingFail
+ transportLogic.pingServer(connection, that.name).done(onComplete).fail(pingFail);
+ };
+
+ pingLoop();
+ },
+
+ start: function(connection, onSuccess, onFailed) {
+ /// Starts the long polling connection
+ /// The SignalR connection to start
+ var that = this,
+ initialConnectedFired = false,
+ fireConnect = function() {
+ if (initialConnectedFired) {
+ return;
+ }
+ initialConnectedFired = true;
+ onSuccess();
+ connection.log("Longpolling connected");
+ },
+ reconnectErrors = 0,
+ reconnectTimeoutId = null,
+ fireReconnected = function(instance) {
+ window.clearTimeout(reconnectTimeoutId);
+ reconnectTimeoutId = null;
+
+ if (changeState(connection,
+ signalR.connectionState.reconnecting,
+ signalR.connectionState.connected) === true) {
+ // Successfully reconnected!
+ connection.log("Raising the reconnect event");
+ $(instance).triggerHandler(events.onReconnect);
+ }
+ },
+ // 1 hour
+ maxFireReconnectedTimeout = 3600000;
+
+ if (connection.pollXhr) {
+ connection.log("Polling xhr requests already exists, aborting.");
+ connection.stop();
+ }
+
+ // We start with an initialization procedure which pings the server to verify that it is there.
+ // On scucessful initialization we'll then proceed with starting the transport.
+ that.init(connection, function() {
+ connection.messageId = null;
+
+ window.setTimeout(function() {
+ ( function poll(instance, raiseReconnect) {
+ var messageId = instance.messageId,
+ connect = (messageId === null),
+ reconnecting = !connect,
+ polling = !raiseReconnect,
+ url = transportLogic.getUrl(instance, that.name, reconnecting, polling);
+
+ // If we've disconnected during the time we've tried to re-instantiate the poll then stop.
+ if (isDisconnecting(instance) === true) {
+ return;
+ }
+
+ connection.log("Attempting to connect to '" + url + "' using longPolling.");
+ instance.pollXhr = $.ajax({
+ url: url,
+ global: true,
+ cache: false,
+ type: "GET",
+ dataType: connection.ajaxDataType,
+ contentType: connection.contentType,
+ success: function(minData) {
+ var delay = 0,
+ data;
+
+ // Reset our reconnect errors so if we transition into a reconnecting state again we trigger
+ // reconnected quickly
+ reconnectErrors = 0;
+
+ // If there's currently a timeout to trigger reconnect, fire it now before processing messages
+ if (reconnectTimeoutId !== null) {
+ fireReconnected();
+ }
+
+ fireConnect();
+
+ if (minData) {
+ data = transportLogic.maximizePersistentResponse(minData);
+ }
+
+ transportLogic.processMessages(instance, minData);
+
+ if (data &&
+ $.type(data.LongPollDelay) === "number") {
+ delay = data.LongPollDelay;
+ }
+
+ if (data && data.Disconnect) {
+ return;
+ }
+
+ if (isDisconnecting(instance) === true) {
+ return;
+ }
+
+ // We never want to pass a raiseReconnect flag after a successful poll. This is handled via the error function
+ if (delay > 0) {
+ window.setTimeout(function() {
+ poll(instance, false);
+ }, delay);
+ } else {
+ poll(instance, false);
+ }
+ },
+
+ error: function(data, textStatus) {
+ // Stop trying to trigger reconnect, connection is in an error state
+ // If we're not in the reconnect state this will noop
+ window.clearTimeout(reconnectTimeoutId);
+ reconnectTimeoutId = null;
+
+ if (textStatus === "abort") {
+ connection.log("Aborted xhr requst.");
+ return;
+ }
+
+ // Increment our reconnect errors, we assume all errors to be reconnect errors
+ // In the case that it's our first error this will cause Reconnect to be fired
+ // after 1 second due to reconnectErrors being = 1.
+ reconnectErrors++;
+
+ if (connection.state !== signalR.connectionState.reconnecting) {
+ connection.log("An error occurred using longPolling. Status = " + textStatus + ". " + data.responseText);
+ $(instance).triggerHandler(events.onError, [data.responseText]);
+ }
+
+ // Transition into the reconnecting state
+ transportLogic.ensureReconnectingState(instance);
+
+ // If we've errored out we need to verify that the server is still there, so re-start initialization process
+ // This will ping the server until it successfully gets a response.
+ that.init(instance, function() {
+ // Call poll with the raiseReconnect flag as true
+ poll(instance, true);
+ });
+ }
+ });
+
+
+ // This will only ever pass after an error has occured via the poll ajax procedure.
+ if (reconnecting && raiseReconnect === true) {
+ // We wait to reconnect depending on how many times we've failed to reconnect.
+ // This is essentially a heuristic that will exponentially increase in wait time before
+ // triggering reconnected. This depends on the "error" handler of Poll to cancel this
+ // timeout if it triggers before the Reconnected event fires.
+ // The Math.min at the end is to ensure that the reconnect timeout does not overflow.
+ reconnectTimeoutId = window.setTimeout(function() {
+ fireReconnected(instance);
+ }, Math.min(1000 * (Math.pow(2, reconnectErrors) - 1), maxFireReconnectedTimeout));
+ }
+ }(connection));
+
+ // Set an arbitrary timeout to trigger onSuccess, this will alot for enough time on the server to wire up the connection.
+ // Will be fixed by #1189 and this code can be modified to not be a timeout
+ window.setTimeout(function() {
+ // Trigger the onSuccess() method because we've now instantiated a connection
+ fireConnect();
+ }, 250);
+ }, 250); // Have to delay initial poll so Chrome doesn't show loader spinner in tab
+ });
+ },
+
+ lostConnection: function(connection) {
+ throw new Error("Lost Connection not handled for LongPolling");
+ },
+
+ send: function(connection, data) {
+ transportLogic.ajaxSend(connection, data);
+ },
+
+ stop: function(connection) {
+ /// Stops the long polling connection
+ /// The SignalR connection to stop
+ if (connection.pollXhr) {
+ connection.pollXhr.abort();
+ connection.pollXhr = null;
+ delete connection.pollXhr;
+ }
+ },
+
+ abort: function(connection, async) {
+ transportLogic.ajaxAbort(connection, async);
+ }
+ };
+}(window.jQuery, window));
+/* jquery.signalR.hubs.js */
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information.
+
+/*global window:false */
+///
+
+( function($, window) {
+ "use strict";
+
+ // we use a global id for tracking callbacks so the server doesn't have to send extra info like hub name
+ var eventNamespace = ".hubProxy";
+
+ function makeEventName(event) {
+ return event + eventNamespace;
+ }
+
+ // Equivalent to Array.prototype.map
+ function map(arr, fun, thisp) {
+ var i,
+ length = arr.length,
+ result = [];
+ for (i = 0; i < length; i += 1) {
+ if (arr.hasOwnProperty(i)) {
+ result[i] = fun.call(thisp, arr[i], i, arr);
+ }
+ }
+ return result;
+ }
+
+ function getArgValue(a) {
+ return $.isFunction(a) ? null : ($.type(a) === "undefined" ? null : a);
+ }
+
+ function hasMembers(obj) {
+ for (var key in obj) {
+ // If we have any properties in our callback map then we have callbacks and can exit the loop via return
+ if (obj.hasOwnProperty(key)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function clearInvocationCallbacks(connection, error) {
+ ///
+ var callbacks = connection._.invocationCallbacks,
+ callback;
+
+ connection.log("Clearing hub invocation callbacks with error: " + error);
+
+ // Reset the callback cache now as we have a local var referencing it
+ connection._.invocationCallbackId = 0;
+ delete connection._.invocationCallbacks;
+ connection._.invocationCallbacks = {};
+
+ // Loop over the callbacks and invoke them.
+ // We do this using a local var reference and *after* we've cleared the cache
+ // so that if a fail callback itself tries to invoke another method we don't
+ // end up with its callback in the list we're looping over.
+ for (var callbackId in callbacks) {
+ callback = callbacks[callbackId];
+ callback.method.call(callback.scope, { E: error });
+ }
+ }
+
+ // hubProxy
+ function hubProxy(hubConnection, hubName) {
+ ///
+ /// Creates a new proxy object for the given hub connection that can be used to invoke
+ /// methods on server hubs and handle client method invocation requests from the server.
+ ///
+ return new hubProxy.fn.init(hubConnection, hubName);
+ }
+
+ hubProxy.fn = hubProxy.prototype = {
+ init: function(connection, hubName) {
+ this.state = {};
+ this.connection = connection;
+ this.hubName = hubName;
+ this._ = {
+ callbackMap: {}
+ };
+ },
+
+ hasSubscriptions: function() {
+ return hasMembers(this._.callbackMap);
+ },
+
+ on: function(eventName, callback) {
+ /// Wires up a callback to be invoked when a invocation request is received from the server hub.
+ /// The name of the hub event to register the callback for.
+ /// The callback to be invoked.
+ var self = this,
+ callbackMap = self._.callbackMap;
+
+ // Normalize the event name to lowercase
+ eventName = eventName.toLowerCase();
+
+ // If there is not an event registered for this callback yet we want to create its event space in the callback map.
+ if (!callbackMap[eventName]) {
+ callbackMap[eventName] = {};
+ }
+
+ // Map the callback to our encompassed function
+ callbackMap[eventName][callback] = function(e, data) {
+ callback.apply(self, data);
+ };
+
+ $(self).bind(makeEventName(eventName), callbackMap[eventName][callback]);
+
+ return self;
+ },
+
+ off: function(eventName, callback) {
+ /// Removes the callback invocation request from the server hub for the given event name.
+ /// The name of the hub event to unregister the callback for.
+ /// The callback to be invoked.
+ var self = this,
+ callbackMap = self._.callbackMap,
+ callbackSpace;
+
+ // Normalize the event name to lowercase
+ eventName = eventName.toLowerCase();
+
+ callbackSpace = callbackMap[eventName];
+
+ // Verify that there is an event space to unbind
+ if (callbackSpace) {
+ // Only unbind if there's an event bound with eventName and a callback with the specified callback
+ if (callbackSpace[callback]) {
+ $(self).unbind(makeEventName(eventName), callbackSpace[callback]);
+
+ // Remove the callback from the callback map
+ delete callbackSpace[callback]
+ ;
+
+ // Check if there are any members left on the event, if not we need to destroy it.
+ if (!hasMembers(callbackSpace)) {
+ delete callbackMap[eventName]
+ ;
+ }
+ } else if (!callback) { // Check if we're removing the whole event and we didn't error because of an invalid callback
+ $(self).unbind(makeEventName(eventName));
+
+ delete callbackMap[eventName]
+ ;
+ }
+ }
+
+ return self;
+ },
+
+ invoke: function(methodName) {
+ /// Invokes a server hub method with the given arguments.
+ /// The name of the server hub method.
+
+ var self = this,
+ connection = self.connection,
+ args = $.makeArray(arguments).slice(1),
+ argValues = map(args, getArgValue),
+ data = { H: self.hubName, M: methodName, A: argValues, I: connection._.invocationCallbackId },
+ d = $.Deferred(),
+ callback = function(minResult) {
+ var result = self._maximizeHubResponse(minResult);
+
+ // Update the hub state
+ $.extend(self.state, result.State);
+
+ if (result.Error) {
+ // Server hub method threw an exception, log it & reject the deferred
+ if (result.StackTrace) {
+ connection.log(result.Error + "\n" + result.StackTrace);
+ }
+ d.rejectWith(self, [result.Error]);
+ } else {
+ // Server invocation succeeded, resolve the deferred
+ d.resolveWith(self, [result.Result]);
+ }
+ };
+
+ connection._.invocationCallbacks[connection._.invocationCallbackId.toString()] = { scope: self, method: callback };
+ connection._.invocationCallbackId += 1;
+
+ if (!$.isEmptyObject(self.state)) {
+ data.S = self.state;
+ }
+
+ connection.send(window.JSON.stringify(data));
+
+ return d.promise();
+ },
+
+ _maximizeHubResponse: function(minHubResponse) {
+ return {
+ State: minHubResponse.S,
+ Result: minHubResponse.R,
+ Id: minHubResponse.I,
+ Error: minHubResponse.E,
+ StackTrace: minHubResponse.T
+ };
+ }
+ };
+
+ hubProxy.fn.init.prototype = hubProxy.fn;
+
+ // hubConnection
+ function hubConnection(url, options) {
+ /// Creates a new hub connection.
+ /// [Optional] The hub route url, defaults to "/signalr".
+ /// [Optional] Settings to use when creating the hubConnection.
+ var settings = {
+ qs: null,
+ logging: false,
+ useDefaultPath: true
+ };
+
+ $.extend(settings, options);
+
+ if (!url || settings.useDefaultPath) {
+ url = (url || "") + "/signalr";
+ }
+ return new hubConnection.fn.init(url, settings);
+ }
+
+ hubConnection.fn = hubConnection.prototype = $.connection();
+
+ hubConnection.fn.init = function(url, options) {
+ var settings = {
+ qs: null,
+ logging: false,
+ useDefaultPath: true
+ },
+ connection = this;
+
+ $.extend(settings, options);
+
+ // Call the base constructor
+ $.signalR.fn.init.call(connection, url, settings.qs, settings.logging);
+
+ // Object to store hub proxies for this connection
+ connection.proxies = {};
+
+ connection._.invocationCallbackId = 0;
+ connection._.invocationCallbacks = {};
+
+ // Wire up the received handler
+ connection.received(function(minData) {
+ var data, proxy, dataCallbackId, callback, hubName, eventName;
+ if (!minData) {
+ return;
+ }
+
+ if (typeof (minData.I) !== "undefined") {
+ // We received the return value from a server method invocation, look up callback by id and call it
+ dataCallbackId = minData.I.toString();
+ callback = connection._.invocationCallbacks[dataCallbackId];
+ if (callback) {
+ // Delete the callback from the proxy
+ connection._.invocationCallbacks[dataCallbackId] = null;
+ delete connection._.invocationCallbacks[dataCallbackId]
+ ;
+
+ // Invoke the callback
+ callback.method.call(callback.scope, minData);
+ }
+ } else {
+ data = this._maximizeClientHubInvocation(minData);
+
+ // We received a client invocation request, i.e. broadcast from server hub
+ connection.log("Triggering client hub event '" + data.Method + "' on hub '" + data.Hub + "'.");
+
+ // Normalize the names to lowercase
+ hubName = data.Hub.toLowerCase();
+ eventName = data.Method.toLowerCase();
+
+ // Trigger the local invocation event
+ proxy = this.proxies[hubName];
+
+ // Update the hub state
+ $.extend(proxy.state, data.State);
+ $(proxy).triggerHandler(makeEventName(eventName), [data.Args]);
+ }
+ });
+
+ connection.error(function(errData, origData) {
+ var data, callbackId, callback;
+
+ if (connection.transport && connection.transport.name === "webSockets") {
+ // WebSockets connections have all callbacks removed on reconnect instead
+ // as WebSockets sends are fire & forget
+ return;
+ }
+
+ if (!origData) {
+ // No original data passed so this is not a send error
+ return;
+ }
+
+ try {
+ data = window.JSON.parse(origData);
+ if (!data.I) {
+ // The original data doesn't have a callback ID so not a send error
+ return;
+ }
+ } catch (e) {
+ // The original data is not a JSON payload so this is not a send error
+ return;
+ }
+
+ callbackId = data.I;
+ callback = connection._.invocationCallbacks[callbackId];
+
+ // Invoke the callback with an error to reject the promise
+ callback.method.call(callback.scope, { E: errData });
+
+ // Delete the callback
+ connection._.invocationCallbacks[callbackId] = null;
+ delete connection._.invocationCallbacks[callbackId]
+ ;
+ });
+
+ connection.reconnecting(function() {
+ if (connection.transport && connection.transport.name === "webSockets") {
+ clearInvocationCallbacks(connection, "Connection started reconnecting before invocation result was received.");
+ }
+ });
+
+ connection.disconnected(function() {
+ clearInvocationCallbacks(connection, "Connection was disconnected before invocation result was received.");
+ });
+ };
+
+ hubConnection.fn._maximizeClientHubInvocation = function(minClientHubInvocation) {
+ return {
+ Hub: minClientHubInvocation.H,
+ Method: minClientHubInvocation.M,
+ Args: minClientHubInvocation.A,
+ State: minClientHubInvocation.S
+ };
+ };
+
+ hubConnection.fn._registerSubscribedHubs = function() {
+ ///
+ /// Sets the starting event to loop through the known hubs and register any new hubs
+ /// that have been added to the proxy.
+ ///
+
+ if (!this._subscribedToHubs) {
+ this._subscribedToHubs = true;
+ this.starting(function() {
+ // Set the connection's data object with all the hub proxies with active subscriptions.
+ // These proxies will receive notifications from the server.
+ var subscribedHubs = [];
+
+ $.each(this.proxies, function(key) {
+ if (this.hasSubscriptions()) {
+ subscribedHubs.push({ name: key });
+ }
+ });
+
+ this.data = window.JSON.stringify(subscribedHubs);
+ });
+ }
+ };
+
+ hubConnection.fn.createHubProxy = function(hubName) {
+ ///
+ /// Creates a new proxy object for the given hub connection that can be used to invoke
+ /// methods on server hubs and handle client method invocation requests from the server.
+ ///
+ ///
+ /// The name of the hub on the server to create the proxy for.
+ ///
+
+ // Normalize the name to lowercase
+ hubName = hubName.toLowerCase();
+
+ var proxy = this.proxies[hubName];
+ if (!proxy) {
+ proxy = hubProxy(this, hubName);
+ this.proxies[hubName] = proxy;
+ }
+
+ this._registerSubscribedHubs();
+
+ return proxy;
+ };
+
+ hubConnection.fn.init.prototype = hubConnection.fn;
+
+ $.hubConnection = hubConnection;
+}(window.jQuery, window));
+/* jquery.signalR.version.js */
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information.
+
+/*global window:false */
+///
+( function($) {
+ $.signalR.version = "1.1.3";
+}(window.jQuery));
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..5e9124db6
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.js
@@ -0,0 +1,200 @@
+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 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,
+ renameEpisodes,
+ episodeFormat,
+ path,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const selectAllValue = getValue(allSelected, allUnselected);
+
+ return (
+
+
+ Organize & Rename
+
+
+
+ {
+ 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,
+ 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..6fbd2b9eb
--- /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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+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,
+ createArtistSelector(),
+ (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/SeasonPass/SeasonPass.js b/frontend/src/SeasonPass/SeasonPass.js
new file mode 100644
index 000000000..681b68fd2
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPass.js
@@ -0,0 +1,257 @@
+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 MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import NoArtist from 'Artist/NoArtist';
+import SeasonPassRowConnector from './SeasonPassRowConnector';
+import SeasonPassFooter from './SeasonPassFooter';
+
+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({
+ artistIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+ Continuing Only
+
+
+
+ Ended Only
+
+
+
+ Missing Episodes
+
+
+
+
+
+
+
+ {
+ 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,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ 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..653db2b02
--- /dev/null
+++ b/frontend/src/SeasonPass/SeasonPassConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setSeasonPassSort, setSeasonPassFilter, saveSeasonPass } from 'Store/Actions/seasonPassActions';
+import SeasonPass from './SeasonPass';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ (series) => {
+ return {
+ ...series
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setSeasonPassSort,
+ setSeasonPassFilter,
+ saveSeasonPass
+};
+
+class SeasonPassConnector extends Component {
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.setSeasonPassSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue, filterType) => {
+ this.props.setSeasonPassFilter({ filterKey, filterValue, filterType });
+ }
+
+ 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 connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'series', uiSection: 'seasonPass' }
+ )(SeasonPassConnector);
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..65984e1f2
--- /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 ArtistNameLink from 'Artist/ArtistNameLink';
+import SeasonPassSeason from './SeasonPassSeason';
+import styles from './SeasonPassRow.css';
+
+class SeasonPassRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistId,
+ status,
+ titleSlug,
+ title,
+ monitored,
+ seasons,
+ isSaving,
+ isSelected,
+ onSelectedChange,
+ onSeriesMonitoredPress,
+ onSeasonMonitoredPress
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ seasons.map((season) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+ }
+}
+
+SeasonPassRow.propTypes = {
+ artistId: 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..366170634
--- /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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { toggleSeriesMonitored, toggleSeasonMonitored } from 'Store/Actions/seriesActions';
+import SeasonPassRow from './SeasonPassRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (series) => {
+ return _.pick(series, [
+ 'status',
+ 'titleSlug',
+ 'title',
+ 'monitored',
+ 'seasons',
+ 'isSaving'
+ ]);
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ toggleSeriesMonitored,
+ toggleSeasonMonitored
+};
+
+class SeasonPassRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onSeriesMonitoredPress = () => {
+ const {
+ artistId,
+ monitored
+ } = this.props;
+
+ this.props.toggleSeriesMonitored({
+ artistId,
+ monitored: !monitored
+ });
+ }
+
+ onSeasonMonitoredPress = (seasonNumber, monitored) => {
+ this.props.toggleSeasonMonitored({
+ artistId: this.props.artistId,
+ seasonNumber,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeasonPassRowConnector.propTypes = {
+ artistId: 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/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
new file mode 100644
index 000000000..414887fd5
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
@@ -0,0 +1,65 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+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.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setDownloadClientOptionsRef = (ref) => {
+ this._downloadClientOptions = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._downloadClientOptions.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default 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..1635faeac
--- /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 {
+ composes: cover from 'Styles/Mixins/cover.css';
+}
+
+.overlay {
+ composes: linkOverlay from 'Styles/Mixins/linkOverlay.css';
+
+ 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..002bd5eb0
--- /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 {
+ isFetching,
+ error,
+ isPopulated,
+ usenetDownloadClients,
+ torrentDownloadClients,
+ onDownloadClientSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add DownloadClient
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new downloadClient, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+
+
+ 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 = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isPopulated: PropTypes.bool.isRequired,
+ 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..d6015b934
--- /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 {
+ isFetching,
+ error,
+ isPopulated,
+ schema
+ } = downloadClients;
+
+ const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' });
+ const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isFetching,
+ error,
+ isPopulated,
+ 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..609cea818
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css
@@ -0,0 +1,19 @@
+.downloadClient {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ 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..b4a725303
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
@@ -0,0 +1,106 @@
+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}
+
+
+
+
+ Enabled
+
+
+
+
+
+
+
+ );
+ }
+}
+
+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..fe5371e4f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
@@ -0,0 +1,117 @@
+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..f82e7eea1
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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..e6778452b
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.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 EditDownloadClientModal from './EditDownloadClientModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditDownloadClientModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'downloadClients' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDownloadClientModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(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..7475084c8
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js
@@ -0,0 +1,177 @@
+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,
+ name,
+ enable,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit DownloadClient' : 'Add DownloadClient'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new downloadClient, 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..bf9acb98c
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditDownloadClientModalContent from './EditDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector(),
+ (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 connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'downloadClients' }
+)(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..2ec9b417d
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
@@ -0,0 +1,118 @@
+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..2acfc6275
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 connectSection from 'Store/connectSection';
+import DownloadClientOptions from './DownloadClientOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClientOptions,
+ setDownloadClientOptionsValue,
+ saveDownloadClientOptions,
+ clearPendingChanges
+};
+
+class DownloadClientOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClientOptions();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.hasPendingChanges !== prevProps.hasPendingChanges) {
+ this.props.onHasPendingChange(this.props.hasPendingChanges);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Control
+
+ save = () => {
+ this.props.saveDownloadClientOptions();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDownloadClientOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientOptionsConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ fetchDownloadClientOptions: PropTypes.func.isRequired,
+ setDownloadClientOptionsValue: PropTypes.func.isRequired,
+ saveDownloadClientOptions: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired,
+ onHasPendingChange: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ { withRef: true },
+ { section: 'downloadClientOptions' }
+ )(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..5ba30d614
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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..bd8bca75f
--- /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: '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..2e21ea87b
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js
@@ -0,0 +1,149 @@
+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,
+ 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,
+ 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..00aa7b8ac
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js
@@ -0,0 +1,119 @@
+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: ''
+};
+
+function createRemotePathMappingSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.remotePathMappings,
+ (id, remotePathMappings) => {
+ 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
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createRemotePathMappingSelector(),
+ (remotePathMapping) => {
+ return {
+ ...remotePathMapping
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setRemotePathMappingValue,
+ saveRemotePathMapping
+};
+
+class EditRemotePathMappingModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newRemotePathMapping).forEach((name) => {
+ this.props.setRemotePathMappingValue({
+ 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.setRemotePathMappingValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveRemotePathMapping({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRemotePathMappingModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setRemotePathMappingValue: PropTypes.func.isRequired,
+ saveRemotePathMapping: 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..c0c0a988f
--- /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..93d022e02
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
@@ -0,0 +1,102 @@
+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..4900119a3
--- /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 = {
+ fetchRemotePathMappings,
+ deleteRemotePathMapping
+};
+
+class RemotePathMappingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRemotePathMappings();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteRemotePathMapping = (id) => {
+ this.props.deleteRemotePathMapping({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RemotePathMappingsConnector.propTypes = {
+ fetchRemotePathMappings: PropTypes.func.isRequired,
+ deleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector);
diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js
new file mode 100644
index 000000000..3ab73148f
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettings.js
@@ -0,0 +1,656 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import ClipboardButton from 'Components/Link/ClipboardButton';
+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 FormInputButton from 'Components/Form/FormInputButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+
+class GeneralSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isConfirmApiKeyResetModalOpen: false,
+ 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
+
+ onApikeyFocus = (event) => {
+ event.target.select();
+ }
+
+ onResetApiKeyPress = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: true });
+ }
+
+ onConfirmResetApiKey = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ this.props.onConfirmResetApiKey();
+ }
+
+ onCloseResetApiKeyModal = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ }
+
+ 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,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isConfirmApiKeyResetModalOpen,
+ isRestartRequiredModalOpen
+ } = this.state;
+
+ const {
+ bindAddress,
+ port,
+ urlBase,
+ enableSsl,
+ sslPort,
+ sslCertHash,
+ launchBrowser,
+ authenticationMethod,
+ username,
+ password,
+ apiKey,
+ proxyEnabled,
+ proxyType,
+ proxyHostname,
+ proxyPort,
+ proxyUsername,
+ proxyPassword,
+ proxyBypassFilter,
+ proxyBypassLocalAddresses,
+ logLevel,
+ analyticsEnabled,
+ branch,
+ updateAutomatically,
+ updateMechanism,
+ updateScriptPath
+ } = settings;
+
+ const authenticationMethodOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'basic', value: 'Basic (Browser Popup)' },
+ { key: 'forms', value: 'Forms (Login Page)' }
+ ];
+
+ const proxyTypeOptions = [
+ { key: 'http', value: 'HTTP(S)' },
+ { key: 'socks4', value: 'Socks4' },
+ { key: 'socks5', value: 'Socks5 (Support TOR)' }
+ ];
+
+ const logLevelOptions = [
+ { key: 'info', value: 'Info' },
+ { key: 'debug', value: 'Debug' },
+ { key: 'trace', value: 'Trace' }
+ ];
+
+ const updateOptions = [
+ { key: 'builtIn', value: 'Built-In' },
+ { key: 'script', value: 'Script' }
+ ];
+
+ const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
+
+ 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..0bc539d3e
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettingsConnector.js
@@ -0,0 +1,117 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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 connectSection from 'Store/connectSection';
+import * as commandNames from 'Commands/commandNames';
+import GeneralSettings from './GeneralSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ createCommandsSelector(),
+ createSystemStatusSelector(),
+ (advancedSettings, sectionSettings, commands, systemStatus) => {
+ const isResettingApiKey = _.some(commands, { name: commandNames.RESET_API_KEY });
+
+ 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: this.props.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 = {
+ section: PropTypes.string.isRequired,
+ 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 connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'general' }
+ )(GeneralSettingsConnector);
diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js
new file mode 100644
index 000000000..2e526c080
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettings.js
@@ -0,0 +1,65 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import IndexersConnector from './Indexers/IndexersConnector';
+import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
+import RestrictionsConnector from './Restrictions/RestrictionsConnector';
+
+class IndexerSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setIndexerOptionsRef = (ref) => {
+ this._indexerOptions = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._indexerOptions.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default 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..e9dd6d1d5
--- /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 {
+ composes: cover from 'Styles/Mixins/cover.css';
+}
+
+.overlay {
+ composes: linkOverlay from 'Styles/Mixins/linkOverlay.css';
+
+ 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..bdb322fe0
--- /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 {
+ isFetching,
+ isPopulated,
+ error,
+ usenetIndexers,
+ torrentIndexers,
+ onIndexerSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Indexer
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ isPopulated && !error &&
+
+
+
+ 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 = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: 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..986466c09
--- /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 {
+ isFetching,
+ error,
+ isPopulated,
+ schema
+ } = indexers;
+
+ const usenetIndexers = _.filter(schema, { protocol: 'usenet' });
+ const torrentIndexers = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isFetching,
+ error,
+ isPopulated,
+ 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..fef70e29f
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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..9e34e93c4
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.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 EditIndexerModal from './EditIndexerModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditIndexerModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'indexers' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditIndexerModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(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..00b41730c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js
@@ -0,0 +1,177 @@
+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,
+ name,
+ enableRss,
+ enableSearch,
+ supportsRss,
+ supportsSearch,
+ fields
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Indexer' : 'Add Indexer'}
+
+
+
+ {
+ 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..c4d9e597e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditIndexerModalContent from './EditIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector(),
+ (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 connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'indexers' }
+)(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..9cd62404c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.css
@@ -0,0 +1,19 @@
+.indexer {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ 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..221a6a239
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js
@@ -0,0 +1,131 @@
+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';
+
+function getLabelKind(supports, enabled) {
+ if (!supports) {
+ return kinds.DEFAULT;
+ }
+
+ if (!enabled) {
+ return kinds.DANGER;
+ }
+
+ return kinds.SUCCESS;
+}
+
+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,
+ enableSearch,
+ supportsRss,
+ supportsSearch
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ RSS
+
+
+
+ Search
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Indexer.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enableRss: PropTypes.bool.isRequired,
+ enableSearch: 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..8b7d37a84
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js
@@ -0,0 +1,117 @@
+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..0a39ec7b7
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
@@ -0,0 +1,93 @@
+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..b88894ebe
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 connectSection from 'Store/connectSection';
+import IndexerOptions from './IndexerOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexerOptions,
+ setIndexerOptionsValue,
+ saveIndexerOptions,
+ clearPendingChanges
+};
+
+class IndexerOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexerOptions();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.hasPendingChanges !== prevProps.hasPendingChanges) {
+ this.props.onHasPendingChange(this.props.hasPendingChanges);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Control
+
+ save = () => {
+ this.props.saveIndexerOptions();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setIndexerOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexerOptionsConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ fetchIndexerOptions: PropTypes.func.isRequired,
+ setIndexerOptionsValue: PropTypes.func.isRequired,
+ saveIndexerOptions: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired,
+ onHasPendingChange: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ { withRef: true },
+ { section: 'indexerOptions' }
+ )(IndexerOptionsConnector);
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js
new file mode 100644
index 000000000..e9f42df98
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditRestrictionModalContentConnector from './EditRestrictionModalContentConnector';
+
+function EditRestrictionModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditRestrictionModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditRestrictionModal;
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js
new file mode 100644
index 000000000..4483f7894
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.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 EditRestrictionModal from './EditRestrictionModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditRestrictionModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'restrictions' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRestrictionModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(EditRestrictionModalConnector);
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js
new file mode 100644
index 000000000..37f8cd760
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js
@@ -0,0 +1,126 @@
+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 './EditRestrictionModalContent.css';
+
+function EditRestrictionModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onModalClose,
+ onSavePress,
+ onDeleteRestrictionPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ required,
+ ignored,
+ tags
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Restriction' : 'Add Restriction'}
+
+
+
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditRestrictionModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteRestrictionPress: PropTypes.func
+};
+
+export default EditRestrictionModalContent;
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js
new file mode 100644
index 000000000..322b0a8d9
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js
@@ -0,0 +1,111 @@
+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 { setRestrictionValue, saveRestriction } from 'Store/Actions/settingsActions';
+import EditRestrictionModalContent from './EditRestrictionModalContent';
+
+const newRestriction = {
+ required: '',
+ ignored: '',
+ tags: []
+};
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.restrictions,
+ (id, restrictions) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = restrictions;
+
+ const profile = id ? _.find(items, { id }) : newRestriction;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setRestrictionValue,
+ saveRestriction
+};
+
+class EditRestrictionModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newRestriction).forEach((name) => {
+ this.props.setRestrictionValue({
+ name,
+ value: newRestriction[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setRestrictionValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveRestriction({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRestrictionModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setRestrictionValue: PropTypes.func.isRequired,
+ saveRestriction: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditRestrictionModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Restrictions/Restriction.css b/frontend/src/Settings/Indexers/Restrictions/Restriction.css
new file mode 100644
index 000000000..0e84466f9
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restriction.css
@@ -0,0 +1,11 @@
+.restriction {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Indexers/Restrictions/Restriction.js b/frontend/src/Settings/Indexers/Restrictions/Restriction.js
new file mode 100644
index 000000000..bdd457aca
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restriction.js
@@ -0,0 +1,147 @@
+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 EditRestrictionModalConnector from './EditRestrictionModalConnector';
+import styles from './Restriction.css';
+
+class Restriction extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditRestrictionModalOpen: false,
+ isDeleteRestrictionModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditRestrictionPress = () => {
+ this.setState({ isEditRestrictionModalOpen: true });
+ }
+
+ onEditRestrictionModalClose = () => {
+ this.setState({ isEditRestrictionModalOpen: false });
+ }
+
+ onDeleteRestrictionPress = () => {
+ this.setState({
+ isEditRestrictionModalOpen: false,
+ isDeleteRestrictionModalOpen: true
+ });
+ }
+
+ onDeleteRestrictionModalClose= () => {
+ this.setState({ isDeleteRestrictionModalOpen: false });
+ }
+
+ onConfirmDeleteRestriction = () => {
+ this.props.onConfirmDeleteRestriction(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ required,
+ ignored,
+ 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}
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Restriction.propTypes = {
+ id: PropTypes.number.isRequired,
+ required: PropTypes.string.isRequired,
+ ignored: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRestriction: PropTypes.func.isRequired
+};
+
+Restriction.defaultProps = {
+ required: '',
+ ignored: ''
+};
+
+export default Restriction;
diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css b/frontend/src/Settings/Indexers/Restrictions/Restrictions.css
new file mode 100644
index 000000000..904a66a57
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restrictions.css
@@ -0,0 +1,20 @@
+.restrictions {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addRestriction {
+ composes: restriction from './Restriction.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/Restrictions/Restrictions.js b/frontend/src/Settings/Indexers/Restrictions/Restrictions.js
new file mode 100644
index 000000000..411b95ea8
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restrictions.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 Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Restriction from './Restriction';
+import EditRestrictionModalConnector from './EditRestrictionModalConnector';
+import styles from './Restrictions.css';
+
+class Restrictions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddRestrictionModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddRestrictionPress = () => {
+ this.setState({ isAddRestrictionModalOpen: true });
+ }
+
+ onAddRestrictionModalClose = () => {
+ this.setState({ isAddRestrictionModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ tagList,
+ onConfirmDeleteRestriction,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Restrictions.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRestriction: PropTypes.func.isRequired
+};
+
+export default Restrictions;
diff --git a/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js
new file mode 100644
index 000000000..c53c05de2
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.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 { fetchRestrictions, deleteRestriction } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import Restrictions from './Restrictions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.restrictions,
+ createTagsSelector(),
+ (restrictions, tagList) => {
+ return {
+ ...restrictions,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRestrictions,
+ deleteRestriction
+};
+
+class RestrictionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRestrictions();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteRestriction = (id) => {
+ this.props.deleteRestriction({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RestrictionsConnector.propTypes = {
+ fetchRestrictions: PropTypes.func.isRequired,
+ deleteRestriction: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RestrictionsConnector);
diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js
new file mode 100644
index 000000000..88ff3cbf5
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -0,0 +1,347 @@
+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';
+
+class MediaManagement extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ isMono,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ const fileDateOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'localAirDate', value: 'Local Air Date' },
+ { key: 'utcAirDate', value: 'UTC Air Date' }
+ ];
+
+ 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..ae3af03f3
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 connectSection from 'Store/connectSection';
+import MediaManagement from './MediaManagement';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.naming,
+ createSettingsSectionSelector(),
+ 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: this.props.section });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMediaManagementSettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMediaManagementSettings();
+ this.props.saveNamingSettings();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MediaManagementConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ fetchMediaManagementSettings: PropTypes.func.isRequired,
+ setMediaManagementSettingsValue: PropTypes.func.isRequired,
+ saveMediaManagementSettings: PropTypes.func.isRequired,
+ saveNamingSettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'mediaManagement' }
+ )(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..a0fbf7f19
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css
@@ -0,0 +1,5 @@
+.namingInput {
+ composes: text 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..91c617c56
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js
@@ -0,0 +1,345 @@
+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('Single Episode: Invalid Format');
+ }
+
+ if (examples.multiEpisodeExample) {
+ standardEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.multiEpisodeExample}`);
+ } else {
+ standardEpisodeFormatErrors.push('Multi Episode: Invalid Format');
+ }
+
+ if (examples.dailyEpisodeExample) {
+ dailyEpisodeFormatHelpTexts.push(`Example: ${examples.dailyEpisodeExample}`);
+ } else {
+ dailyEpisodeFormatErrors.push('Invalid Format');
+ }
+
+ if (examples.animeEpisodeExample) {
+ animeEpisodeFormatHelpTexts.push(`Single Episode: ${examples.animeEpisodeExample}`);
+ } else {
+ animeEpisodeFormatErrors.push('Single Episode: Invalid Format');
+ }
+
+ if (examples.animeMultiEpisodeExample) {
+ animeEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.animeMultiEpisodeExample}`);
+ } else {
+ animeEpisodeFormatErrors.push('Multi Episode: Invalid Format');
+ }
+
+ if (examples.seriesFolderExample) {
+ seriesFolderFormatHelpTexts.push(`Example: ${examples.seriesFolderExample}`);
+ } else {
+ seriesFolderFormatErrors.push('Invalid Format');
+ }
+
+ if (examples.seasonFolderExample) {
+ seasonFolderFormatHelpTexts.push(`Example: ${examples.seasonFolderExample}`);
+ } else {
+ seasonFolderFormatErrors.push('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..59355c4b4
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
@@ -0,0 +1,102 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 connectSection from 'Store/connectSection';
+import Naming from './Naming';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.namingExamples,
+ createSettingsSectionSelector(),
+ (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: this.props.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 = {
+ section: PropTypes.string.isRequired,
+ fetchNamingSettings: PropTypes.func.isRequired,
+ setNamingSettingsValue: PropTypes.func.isRequired,
+ fetchNamingExamples: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'naming' }
+ )(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..708a763eb
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
@@ -0,0 +1,17 @@
+.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';
+
+ 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..59800e52a
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
@@ -0,0 +1,469 @@
+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';
+
+class NamingModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ case: 'title'
+ };
+ }
+
+ //
+ // Listeners
+
+ onNamingCaseChange = (event) => {
+ this.setState({ case: event.value });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ value,
+ isOpen,
+ advancedSettings,
+ season,
+ episode,
+ daily,
+ anime,
+ additional,
+ onInputChange,
+ onModalClose
+ } = this.props;
+
+ const namingOptions = [
+ { 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 (2010)' },
+ { token: '{Series.Title}', example: 'Series.Title.(2010)' },
+ { token: '{Series_Title}', example: 'Series_Title_(2010)' },
+
+ { token: '{Series TitleThe}', example: 'Series Title, The (2010)' },
+
+ { token: '{Series CleanTitle}', example: 'Series Title 2010' },
+ { token: '{Series.CleanTitle}', example: 'Series.Title.2010' },
+ { token: '{Series_CleanTitle}', example: 'Series_Title_2010' }
+ ];
+
+ 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' },
+ { 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.Title}', example: 'Episode.Title' },
+ { token: '{Episode_Title}', example: 'Episode_Title' },
+ { token: '{Episode CleanTitle}', example: 'Episode Title' },
+ { token: '{Episode.CleanTitle}', example: 'Episode.Title' },
+ { token: '{Episode_CleanTitle}', example: 'Episode_Title' }
+ ];
+
+ const qualityTokens = [
+ { token: '{Quality Full}', example: 'HDTV 720p Proper' },
+ { token: '{Quality-Full}', example: 'HDTV-720p-Proper' },
+ { token: '{Quality.Full}', example: 'HDTV.720p.Proper' },
+ { token: '{Quality_Full}', example: 'HDTV_720p_Proper' },
+ { token: '{Quality Title}', example: 'HDTV 720p' },
+ { token: '{Quality-Title}', example: 'HDTV-720p' },
+ { token: '{Quality.Title}', example: 'HDTV.720p' },
+ { token: '{Quality_Title}', example: 'HDTV_720p' }
+ ];
+
+ const mediaInfoTokens = [
+ { token: '{MediaInfo Simple}', example: 'x264 DTS' },
+ { token: '{MediaInfo.Simple}', example: 'x264.DTS' },
+ { token: '{MediaInfo_Simple}', example: 'x264_DTS' },
+ { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' },
+ { token: '{MediaInfo.Full}', example: 'x264.DTS.[EN+DE]' },
+ { 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 releaseGroupTokens = [
+ { token: '{Release Group}', example: 'Rls Grp' },
+ { token: '{Release.Group}', example: 'Rls.Grp' },
+ { token: '{Release_Group}', example: 'Rls_Grp' }
+ ];
+
+ const originalTokens = [
+ { token: '{Original Title}', example: 'Series.Title.S01E01.HDTV.x264-EVOLVE' },
+ { token: '{Original Filename}', example: 'series.title.s01e01.hdtv.x264-EVOLVE' }
+ ];
+
+ return (
+
+
+
+ File Name Tokens
+
+
+
+
+
+
+
+ {
+ !advancedSettings &&
+
+
+ {
+ fileNameTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+
+
+ {
+ seriesTokens.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 (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ releaseGroupTokens.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..299c98936
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
@@ -0,0 +1,66 @@
+.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: 420px;
+}
+
+.large {
+ width: 100%;
+}
+
+.token {
+ flex: 0 0 50%;
+ padding: 6px 16px;
+ background-color: #eee;
+ font-family: $monoSpaceFontFamily;
+}
+
+.example {
+ 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..ee8361a14
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
@@ -0,0 +1,85 @@
+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 {
+ name,
+ value,
+ token,
+ tokenCase,
+ isFullFilename,
+ onInputChange
+ } = this.props;
+
+ let newValue = token;
+
+ if (tokenCase === 'lower') {
+ newValue = token.toLowerCase();
+ } else if (tokenCase === 'upper') {
+ newValue = token.toUpperCase();
+ }
+
+ if (isFullFilename) {
+ onInputChange({ name, value: newValue });
+ } else {
+ onInputChange({
+ name,
+ value: `${value}${newValue}`
+ });
+ }
+ }
+
+ //
+ // Render
+ render() {
+ const {
+ token,
+ example,
+ tokenCase,
+ isFullFilename,
+ size
+ } = this.props;
+
+ return (
+
+ {token}
+ {example}
+
+ );
+ }
+}
+
+NamingOption.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ token: PropTypes.string.isRequired,
+ example: PropTypes.string.isRequired,
+ tokenCase: PropTypes.string.isRequired,
+ isFullFilename: PropTypes.bool.isRequired,
+ size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
+ onInputChange: 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..98631932a
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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..a065e2f08
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.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 EditMetadataModal from './EditMetadataModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditMetadataModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'metadatas' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(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..9ed3aa48f
--- /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, 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 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..2de4023de
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css
@@ -0,0 +1,17 @@
+.metadata {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.label {
+ composes: label from 'Components/Label.css';
+
+ width: 100%;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js
new file mode 100644
index 000000000..fb6495f7c
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js
@@ -0,0 +1,103 @@
+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';
+
+function getKind(enable) {
+ if (enable) {
+ return kinds.SUCCESS;
+ }
+
+ return kinds.DANGER;
+}
+
+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;
+
+ return (
+
+
+ {name}
+
+
+
+
+ Enable
+
+
+
+
+ {
+ fields.map((field) => {
+ 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..03343ad82
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
@@ -0,0 +1,46 @@
+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..3415de2e1
--- /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 {
+ composes: cover from 'Styles/Mixins/cover.css';
+}
+
+.overlay {
+ composes: linkOverlay from 'Styles/Mixins/linkOverlay.css';
+
+ 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..fa5a4af4f
--- /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 {
+ isFetching,
+ error,
+ isPopulated,
+ schema,
+ onNotificationSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Notification
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new notification, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+
+ {
+ schema.map((notification) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddNotificationModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isPopulated: PropTypes.bool.isRequired,
+ 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..a724ba35b
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.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 { fetchNotificationSchema, selectNotificationSchema } from 'Store/Actions/settingsActions';
+import AddNotificationModalContent from './AddNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.notifications,
+ (notifications) => {
+ const {
+ isFetching,
+ error,
+ isPopulated,
+ schema
+ } = notifications;
+
+ return {
+ isFetching,
+ error,
+ isPopulated,
+ 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..91d9f67cc
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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..3e47e7384
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.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 EditNotificationModal from './EditNotificationModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditNotificationModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'notifications' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(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..f3af135f2
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
@@ -0,0 +1,235 @@
+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,
+ name,
+ onGrab,
+ onDownload,
+ onUpgrade,
+ onRename,
+ supportsOnGrab,
+ supportsOnDownload,
+ supportsOnUpgrade,
+ supportsOnRename,
+ tags,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Notification' : 'Add Notification'}
+
+
+
+ {
+ 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..bca296315
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setNotificationValue, setNotificationFieldValue, saveNotification, testNotification } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditNotificationModalContent from './EditNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector(),
+ (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 connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'notifications' }
+)(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..8d28cef8e
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.css
@@ -0,0 +1,19 @@
+.notification {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ 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..cca3761fc
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.js
@@ -0,0 +1,151 @@
+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';
+
+function getLabelKind(supports, enabled) {
+ if (!supports) {
+ return kinds.DEFAULT;
+ }
+
+ if (!enabled) {
+ return kinds.DANGER;
+ }
+
+ return kinds.SUCCESS;
+}
+
+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}
+
+
+
+ On Grab
+
+
+
+ On Download
+
+
+
+ On Upgrade
+
+
+
+ On Rename
+
+
+
+
+
+
+ );
+ }
+}
+
+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..38d017062
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js
@@ -0,0 +1,117 @@
+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..5754cb657
--- /dev/null
+++ b/frontend/src/Settings/PendingChangesModal.js
@@ -0,0 +1,64 @@
+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';
+
+function PendingChangesModal(props) {
+ const {
+ isOpen,
+ size,
+ 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),
+ size: PropTypes.oneOf(sizes.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..b7be1622e
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
@@ -0,0 +1,174 @@
+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,
+ order,
+ 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,
+ order: 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..7fc0cab2a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
@@ -0,0 +1,150 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Measure from 'react-measure';
+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 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..a593471ac
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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..1f696c846
--- /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: '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..42c6bece0
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
@@ -0,0 +1,186 @@
+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..8cd001950
--- /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: []
+};
+
+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 protocolOptions = [
+ { key: 'preferUsenet', value: 'Prefer Usenet' },
+ { key: 'preferTorrent', value: 'Prefer Torrent' },
+ { key: 'onlyUsenet', value: 'Only Usenet' },
+ { key: 'onlyTorrent', value: 'Only Torrent' }
+ ];
+
+ 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..7fe5a1823
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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..44866e2a3
--- /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: '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..aab5146be
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js
@@ -0,0 +1,149 @@
+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,
+ 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..13f72f623
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js
@@ -0,0 +1,194 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 connectSection from 'Store/connectSection';
+import EditLanguageProfileModalContent from './EditLanguageProfileModalContent';
+
+function createLanguagesSelector() {
+ return createSelector(
+ createProviderSettingsSelector(),
+ (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(),
+ 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.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,
+ 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 connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'languageProfiles' }
+)(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..f94aef43a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.css
@@ -0,0 +1,19 @@
+.languageProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 300px;
+}
+
+.name {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.languages {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.js b/frontend/src/Settings/Profiles/Language/LanguageProfile.js
new file mode 100644
index 000000000..a13a5509d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.js
@@ -0,0 +1,124 @@
+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 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 });
+ }
+
+ onConfirmDeleteLanguageProfile = () => {
+ this.props.onConfirmDeleteLanguageProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ cutoff,
+ languages,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ languages.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ const isCutoff = item.language.id === cutoff.id;
+
+ return (
+
+ {item.language.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ cutoff: PropTypes.object.isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteLanguageProfile: 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..0b6b27d8e
--- /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 formLabelWidth = parseInt(dimensions.formLabelWidth);
+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 - formLabelWidth - 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..2c2df29aa
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js
@@ -0,0 +1,102 @@
+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
+
+ onEditLanguageProfilePress = () => {
+ this.setState({ isLanguageProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isLanguageProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteLanguageProfile,
+ ...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
+};
+
+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..2dc8967eb
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchLanguageProfiles, deleteLanguageProfile } 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 = {
+ fetchLanguageProfiles,
+ deleteLanguageProfile
+};
+
+class LanguageProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLanguageProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteLanguageProfile = (id) => {
+ this.props.deleteLanguageProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LanguageProfilesConnector.propTypes = {
+ fetchLanguageProfiles: PropTypes.func.isRequired,
+ deleteLanguageProfile: 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..ed288ca9b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Profiles.js
@@ -0,0 +1,36 @@
+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';
+
+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..13539e302
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
+
+function EditQualityProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ 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..5ec77950f
--- /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: '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..74dd1c8b7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
@@ -0,0 +1,3 @@
+.deleteButtonContainer {
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
new file mode 100644
index 000000000..921aa94a8
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
@@ -0,0 +1,149 @@
+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 QualityProfileItems from './QualityProfileItems';
+import styles from './EditQualityProfileModalContent.css';
+
+function EditQualityProfileModalContent(props) {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ qualities,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteQualityProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ 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 = {
+ 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,
+ 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..9de5080ca
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
@@ -0,0 +1,194 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 connectSection from 'Store/connectSection';
+import EditQualityProfileModalContent from './EditQualityProfileModalContent';
+
+function createQualitiesSelector() {
+ return createSelector(
+ createProviderSettingsSelector(),
+ (qualityProfile) => {
+ const items = qualityProfile.item.items;
+ if (!items || !items.value) {
+ return [];
+ }
+
+ return _.reduceRight(items.value, (result, { allowed, quality }) => {
+ if (allowed) {
+ result.push({
+ key: quality.id,
+ value: quality.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector(),
+ 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 = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // 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) => i.quality.id === id);
+
+ this.props.setQualityProfileValue({ name, value: item.quality });
+ }
+
+ onSavePress = () => {
+ this.props.saveQualityProfile({ id: this.props.id });
+ }
+
+ onQualityProfileItemAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(qualityProfile.items.value, (i) => i.quality.id === id);
+ item.allowed = allowed;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: qualityProfile.items.value
+ });
+
+ const cutoff = qualityProfile.cutoff.value;
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !_.find(qualityProfile.items.value, (i) => i.quality.id === cutoff.id).allowed) {
+ const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
+
+ this.props.setQualityProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.quality : null });
+ }
+ }
+
+ onQualityProfileItemDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onQualityProfileItemDragEnd = ({ id }, didDrop) => {
+ const {
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ const qualityProfile = _.cloneDeep(this.props.item);
+
+ const items = qualityProfile.items.value.splice(dragIndex, 1);
+ qualityProfile.items.value.splice(dropIndex, 0, items[0]);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: qualityProfile.items.value
+ });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: 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 connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'qualityProfiles' }
+)(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..785047cd4
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
@@ -0,0 +1,19 @@
+.qualityProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 300px;
+}
+
+.name {
+ composes: truncate from 'Styles/Mixins/truncate.css';
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.qualities {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
new file mode 100644
index 000000000..211656ab1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
@@ -0,0 +1,124 @@
+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 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);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ cutoff,
+ items,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ items.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ const isCutoff = item.quality.id === cutoff.id;
+
+ return (
+
+ {item.quality.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ cutoff: PropTypes.object.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteQualityProfile: 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..90d48a2c5
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
@@ -0,0 +1,44 @@
+.qualityProfileItem {
+ 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;
+}
+
+.qualityName {
+ 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/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
new file mode 100644
index 000000000..684b0c905
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.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 './QualityProfileItem.css';
+
+class QualityProfileItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ qualityId,
+ onQualityProfileItemAllowedChange
+ } = this.props;
+
+ onQualityProfileItemAllowedChange(qualityId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+QualityProfileItem.propTypes = {
+ qualityId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func
+};
+
+QualityProfileItem.defaultProps = {
+ // 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..1fd249714
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.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 QualityProfileItem from './QualityProfileItem';
+import styles from './QualityProfileItemDragPreview.css';
+
+const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
+const formLabelWidth = parseInt(dimensions.formLabelWidth);
+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 = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ const {
+ qualityId,
+ name,
+ allowed,
+ sortIndex
+ } = item;
+
+ 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..5b9f36fe9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
@@ -0,0 +1,18 @@
+.qualityProfileItemDragSource {
+ padding: 4px 0;
+}
+
+.qualityProfileItemPlaceholder {
+ width: 100%;
+ height: 36px;
+ 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..ed8adc107
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.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 QualityProfileItem from './QualityProfileItem';
+import styles from './QualityProfileItemDragSource.css';
+
+const qualityProfileItemDragSource = {
+ beginDrag({ qualityId, name, allowed, sortIndex }) {
+ return {
+ qualityId,
+ name,
+ allowed,
+ sortIndex
+ };
+ },
+
+ endDrag(props, monitor, component) {
+ props.onQualityProfileItemDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const qualityProfileItemDropTarget = {
+ 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.onQualityProfileItemDragMove(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 QualityProfileItemDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ qualityId,
+ name,
+ allowed,
+ sortIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ onQualityProfileItemAllowedChange
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemDragSource.propTypes = {
+ qualityId: 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,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ 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/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
new file mode 100644
index 000000000..344df5b08
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
@@ -0,0 +1,6 @@
+.qualities {
+ margin-top: 10px;
+ /* TODO: This should consider the number of qualities in the list */
+ min-height: 550px;
+ 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..5a58da630
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.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 QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
+import styles from './QualityProfileItems.css';
+
+class QualityProfileItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ dragIndex,
+ dropIndex,
+ qualityProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex > dragIndex;
+ const isDraggingDown = isDragging && dropIndex < dragIndex;
+
+ return (
+
+ Qualities
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ qualityProfileItems.map(({ allowed, quality }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItems.propTypes = {
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object)
+};
+
+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..75f049695
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
@@ -0,0 +1,102 @@
+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
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isQualityProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isQualityProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteQualityProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfiles.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteQualityProfile: 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..4bb1529ee
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfiles, deleteQualityProfile } from 'Store/Actions/settingsActions';
+import QualityProfiles from './QualityProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.qualityProfiles,
+ (advancedSettings, qualityProfiles) => {
+ return {
+ advancedSettings,
+ ...qualityProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfiles,
+ deleteQualityProfile
+};
+
+class QualityProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchQualityProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteQualityProfile = (id) => {
+ this.props.deleteQualityProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfilesConnector.propTypes = {
+ fetchQualityProfiles: PropTypes.func.isRequired,
+ deleteQualityProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
new file mode 100644
index 000000000..5eced5bf4
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
@@ -0,0 +1,95 @@
+.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: 6px;
+ margin: 0 5px;
+ height: 10px;
+ border: 1px solid $sliderAccentColor;
+ border-radius: 4px;
+ background-color: $sliderAccentColor;
+ box-shadow: 0 0 0 #000;
+
+ &:nth-child(odd) {
+ background-color: $white;
+ }
+}
+
+.handle {
+ top: 1px;
+ z-index: 0 !important;
+ width: 20px;
+ height: 20px;
+ border: 1px 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: text 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..b1c715d8c
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
@@ -0,0 +1,166 @@
+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 {
+
+ //
+ // 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..be83cc069
--- /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: '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..8eac34c8b
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
@@ -0,0 +1,65 @@
+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..2170919be
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
@@ -0,0 +1,78 @@
+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 = {
+ fetchQualityDefinitions,
+ saveQualityDefinitions
+};
+
+class QualityDefinitionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchQualityDefinitions();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges
+ } = this.props;
+
+ if (hasPendingChanges !== prevProps.hasPendingChanges) {
+ this.props.onHasPendingChange(hasPendingChanges);
+ }
+ }
+
+ //
+ // Control
+
+ save = () => {
+ this.props.saveQualityDefinitions();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionsConnector.propTypes = {
+ hasPendingChanges: PropTypes.bool.isRequired,
+ fetchQualityDefinitions: PropTypes.func.isRequired,
+ saveQualityDefinitions: PropTypes.func.isRequired,
+ onHasPendingChange: 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..ed58dac67
--- /dev/null
+++ b/frontend/src/Settings/Quality/Quality.js
@@ -0,0 +1,59 @@
+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.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setQualityDefinitionsRef = (ref) => {
+ this._qualityDefinitions = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._qualityDefinitions.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Quality;
diff --git a/frontend/src/Settings/SettingsToolbar.css b/frontend/src/Settings/SettingsToolbar.css
new file mode 100644
index 000000000..2d3aa1c6f
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbar.css
@@ -0,0 +1,7 @@
+.advancedSettings {
+ composes: toolbarButton from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.advancedSettingsEnabled {
+ color: $toobarButtonHoverColor;
+}
diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js
new file mode 100644
index 000000000..0d758ce23
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbar.js
@@ -0,0 +1,104 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+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 styles from './SettingsToolbar.css';
+
+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,
+ onSavePress,
+ onConfirmNavigation,
+ onCancelNavigation,
+ onAdvancedSettingsPress
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ showSave &&
+
+ }
+
+
+
+ );
+ }
+}
+
+SettingsToolbar.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ showSave: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool,
+ hasPendingLocation: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool,
+ 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/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js
new file mode 100644
index 000000000..4bc7d0ed1
--- /dev/null
+++ b/frontend/src/Shared/piwikCheck.js
@@ -0,0 +1,10 @@
+if (window.Sonarr.analytics) {
+ var d = document;
+ var g = d.createElement('script');
+ var 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..1d32f35f3
--- /dev/null
+++ b/frontend/src/Shims/jquery.js
@@ -0,0 +1,8 @@
+var jquery = require('JsLibraries/jquery');
+var ajax = require('jQuery/jquery.ajax');
+
+ajax(jquery);
+
+window.$ = jquery;
+window.jQuery = jquery;
+module.exports = jquery;
diff --git a/frontend/src/Shims/signalR.js b/frontend/src/Shims/signalR.js
new file mode 100644
index 000000000..22fb16b91
--- /dev/null
+++ b/frontend/src/Shims/signalR.js
@@ -0,0 +1,4 @@
+require('jquery');
+const signalR = require('JsLibraries/jquery.signalR');
+
+module.exports = signalR;
diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
new file mode 100644
index 000000000..1017d261d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import updateEpisodes from 'Utilities/Episode/updateEpisodes';
+
+function createBatchToggleEpisodeMonitoredHandler(section, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const {
+ episodeIds,
+ monitored
+ } = payload;
+
+ const state = getFromState(getState());
+
+ updateEpisodes(dispatch, section, state.items, episodeIds, {
+ isSaving: true
+ });
+
+ const promise = $.ajax({
+ url: '/episode/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ episodeIds, monitored }),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ updateEpisodes(dispatch, section, state.items, episodeIds, {
+ isSaving: false,
+ monitored
+ });
+ });
+
+ promise.fail(() => {
+ updateEpisodes(dispatch, 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..5bf31b92e
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js
@@ -0,0 +1,46 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, update, updateItem } from '../baseActions';
+
+function createFetchHandler(section, url) {
+ return function(payload = {}) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const promise = $.ajax({
+ url: id == null ? url : `${url}/${id}`,
+ data: otherPayload,
+ traditional: true
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ id == null ? update({ section, data }) : updateItem({ section, ...data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ };
+}
+
+export default createFetchHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
new file mode 100644
index 000000000..e58811ee4
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
@@ -0,0 +1,35 @@
+import $ from 'jquery';
+import { set } from '../baseActions';
+
+function createFetchSchemaHandler(section, url) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetchingSchema: true }));
+
+ const promise = $.ajax({
+ url
+ });
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isFetchingSchema: false,
+ schemaPopulated: true,
+ schemaError: null,
+ schema: data
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetchingSchema: false,
+ schemaPopulated: 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..4f8d779a8
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
@@ -0,0 +1,54 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, updateServerSideCollection } from '../baseActions';
+
+function createFetchServerSideCollectionHandler(section, url, getFromState) {
+ return function(payload = {}) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ const state = getFromState(getState());
+ const sectionState = state.hasOwnProperty(section) ? state[section] : state;
+ const page = payload.page || sectionState.page || 1;
+
+ const data = Object.assign({ page },
+ _.pick(sectionState, [
+ 'pageSize',
+ 'sortDirection',
+ 'sortKey',
+ 'filterKey',
+ 'filterValue'
+ ]));
+
+ 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/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
new file mode 100644
index 000000000..f09a05948
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
@@ -0,0 +1,47 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, removeItem } from '../baseActions';
+
+function createRemoveItemHandler(section, url) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const {
+ id,
+ ...queryParms
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const ajaxOptions = {
+ url: `${url}/${id}?${$.param(queryParms, true)}`,
+ method: 'DELETE'
+ };
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ removeItem({ section, id }),
+
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ })
+ ]));
+ });
+
+ 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..76048192e
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js
@@ -0,0 +1,44 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, update } from '../baseActions';
+
+function createSaveHandler(section, url, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isSaving: true }));
+
+ const state = getFromState(getState());
+ const saveData = Object.assign({}, state.item, state.pendingChanges);
+
+ 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..3a0148c71
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
@@ -0,0 +1,53 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set, updateItem } from '../baseActions';
+
+function createSaveProviderHandler(section, url, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isSaving: true }));
+
+ const id = payload.id;
+ const saveData = getProviderState(payload, getState, getFromState);
+
+ const ajaxOptions = {
+ url,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ };
+
+ if (id) {
+ ajaxOptions.url = `${url}/${id}`;
+ ajaxOptions.method = 'PUT';
+ }
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section, ...data }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: 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..91cef5d5e
--- /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, getFromState, handlers) {
+ const actionHandlers = {};
+ const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH];
+ const fetchHandler = createFetchServerSideCollectionHandler(section, url, getFromState);
+ actionHandlers[fetchHandlerType] = fetchHandler;
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) {
+ const handlerType = handlers[serverSideCollectionHandlers.SORT];
+ actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FILTER];
+ actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, getFromState, fetchHandler);
+ }
+
+ 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..0aaa342db
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
@@ -0,0 +1,12 @@
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionFilterHandler(section, getFromState, fetchHandler) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ 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..88682f118
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
@@ -0,0 +1,37 @@
+import pages from 'Utilities/pages';
+
+function createSetServerSideCollectionPageHandler(section, page, getFromState, fetchHandler) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const state = getFromState(getState());
+ const sectionState = state.hasOwnProperty(section) ? state[section] : state;
+ 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..457108894
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
@@ -0,0 +1,28 @@
+import { sortDirections } from 'Helpers/Props';
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionSortHandler(section, getFromState, fetchHandler) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const state = getFromState(getState());
+ const sectionState = state.hasOwnProperty(section) ? state[section] : state;
+ 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/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
new file mode 100644
index 000000000..3dbc1a113
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set } from '../baseActions';
+
+function createTestProviderHandler(section, url, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isTesting: true }));
+
+ const testData = getProviderState(payload, getState, getFromState);
+
+ const ajaxOptions = {
+ url: `${url}/test`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(testData)
+ };
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: xhr
+ }));
+ });
+ };
+ };
+}
+
+export default createTestProviderHandler;
diff --git a/frontend/src/Store/Actions/Creators/createToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createToggleEpisodeMonitoredHandler.js
new file mode 100644
index 000000000..8480ed067
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createToggleEpisodeMonitoredHandler.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import updateEpisodes from 'Utilities/Episode/updateEpisodes';
+
+function createToggleEpisodeMonitoredHandler(section, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const {
+ episodeId,
+ monitored
+ } = payload;
+
+ const state = getFromState(getState());
+
+ updateEpisodes(dispatch, section, state.items, [episodeId], {
+ isSaving: true
+ });
+
+ const promise = $.ajax({
+ url: `/episode/${episodeId}`,
+ method: 'PUT',
+ data: JSON.stringify({ monitored }),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ updateEpisodes(dispatch, section, state.items, [episodeId], {
+ isSaving: false,
+ monitored
+ });
+ });
+
+ promise.fail(() => {
+ updateEpisodes(dispatch, section, state.items, [episodeId], {
+ isSaving: false
+ });
+ });
+ };
+ };
+}
+
+export default createToggleEpisodeMonitoredHandler;
diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js
new file mode 100644
index 000000000..5d00df4f3
--- /dev/null
+++ b/frontend/src/Store/Actions/actionTypes.js
@@ -0,0 +1,394 @@
+//
+// BASE
+
+export const SET = 'SET';
+
+export const UPDATE = 'UPDATE';
+export const UPDATE_ITEM = 'UPDATE_ITEM';
+export const UPDATE_SERVER_SIDE_COLLECTION = 'UPDATE_SERVER_SIDE_COLLECTION';
+
+export const SET_SETTING_VALUE = 'SET_SETTING_VALUE';
+export const CLEAR_PENDING_CHANGES = 'CLEAR_PENDING_CHANGES';
+export const SAVE_SETTINGS = 'SAVE_SETTINGS';
+
+export const REMOVE_ITEM = 'REMOVE_ITEM';
+
+//
+// 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';
+
+//
+// Add Series
+
+export const LOOKUP_SERIES = 'LOOKUP_SERIES';
+export const ADD_SERIES = 'ADD_SERIES';
+export const SET_ADD_SERIES_VALUE = 'SET_ADD_SERIES_VALUE';
+export const CLEAR_ADD_SERIES = 'CLEAR_ADD_SERIES';
+export const SET_ADD_SERIES_DEFAULT = 'SET_ADD_SERIES_DEFAULT';
+
+//
+// Import Series
+
+export const QUEUE_LOOKUP_SERIES = 'QUEUE_LOOKUP_SERIES';
+export const START_LOOKUP_SERIES = 'START_LOOKUP_SERIES';
+export const CLEAR_IMPORT_SERIES = 'CLEAR_IMPORT_SERIES';
+export const SET_IMPORT_SERIES_VALUE = 'SET_IMPORT_SERIES_VALUE';
+export const IMPORT_SERIES = 'IMPORT_SERIES';
+
+//
+// Series
+
+export const FETCH_ARTIST = 'FETCH_ARTIST';
+export const SET_ARTIST_VALUE = 'SET_ARTIST_VALUE';
+export const SAVE_ARTIST = 'SAVE_ARTIST';
+export const DELETE_ARTIST = 'DELETE_ARTIST';
+
+export const SET_ARTIST_SORT = 'SET_ARTIST_SORT';
+export const SET_ARTIST_FILTER = 'SET_ARTIST_FILTER';
+export const SET_ARTIST_VIEW = 'SET_ARTIST_VIEW';
+export const SET_ARTIST_TABLE_OPTION = 'SET_ARTIST_TABLE_OPTION';
+export const SET_ARTIST_POSTER_OPTION = 'SET_ARTIST_POSTER_OPTION';
+
+export const TOGGLE_ARTIST_MONITORED = 'TOGGLE_ARTIST_MONITORED';
+export const TOGGLE_ALBUM_MONITORED = 'TOGGLE_ALBUM_MONITORED';
+
+//
+// Series Editor
+
+export const SET_SERIES_EDITOR_SORT = 'SET_SERIES_EDITOR_SORT';
+export const SET_SERIES_EDITOR_FILTER = 'SET_SERIES_EDITOR_FILTER';
+export const SAVE_ARTIST_EDITOR = 'SAVE_ARTIST_EDITOR';
+export const BULK_DELETE_ARTIST = 'BULK_DELETE_ARTIST';
+
+//
+// Season Pass
+
+export const SET_SEASON_PASS_SORT = 'SET_SEASON_PASS_SORT';
+export const SET_SEASON_PASS_FILTER = 'SET_SEASON_PASS_FILTER';
+export const SAVE_SEASON_PASS = 'SAVE_SEASON_PASS';
+
+//
+// Episodes
+
+export const FETCH_EPISODES = 'FETCH_EPISODES';
+export const SET_EPISODES_SORT = 'SET_EPISODES_SORT';
+export const SET_EPISODES_TABLE_OPTION = 'SET_EPISODES_TABLE_OPTION';
+export const CLEAR_EPISODES = 'CLEAR_EPISODES';
+export const TOGGLE_EPISODE_MONITORED = 'TOGGLE_EPISODE_MONITORED';
+export const TOGGLE_EPISODES_MONITORED = 'TOGGLE_EPISODES_MONITORED';
+
+//
+// Episode Files
+
+export const FETCH_EPISODE_FILES = 'FETCH_EPISODE_FILES';
+export const CLEAR_EPISODE_FILES = 'CLEAR_EPISODE_FILES';
+export const DELETE_EPISODE_FILE = 'DELETE_EPISODE_FILE';
+export const DELETE_EPISODE_FILES = 'DELETE_EPISODE_FILES';
+export const UPDATE_EPISODE_FILES = 'UPDATE_EPISODE_FILES';
+
+//
+// Episode History
+
+export const FETCH_EPISODE_HISTORY = 'FETCH_EPISODE_HISTORY';
+export const CLEAR_EPISODE_HISTORY = 'CLEAR_EPISODE_HISTORY';
+export const EPISODE_HISTORY_MARK_AS_FAILED = 'EPISODE_HISTORY_MARK_AS_FAILED';
+
+//
+// Releases
+
+export const FETCH_RELEASES = 'FETCH_RELEASES';
+export const SET_RELEASES_SORT = 'SET_RELEASES_SORT';
+export const CLEAR_RELEASES = 'CLEAR_RELEASES';
+export const GRAB_RELEASE = 'GRAB_RELEASE';
+export const UPDATE_RELEASE = 'UPDATE_RELEASE';
+
+//
+// Calendar
+
+export const FETCH_CALENDAR = 'FETCH_CALENDAR';
+export const SET_CALENDAR_DAYS_COUNT = 'SET_CALENDAR_DAYS_COUNT';
+export const SET_CALENDAR_INCLUDE_UNMONITORED = 'SET_CALENDAR_INCLUDE_UNMONITORED';
+export const SET_CALENDAR_VIEW = 'SET_CALENDAR_VIEW';
+export const GOTO_CALENDAR_TODAY = 'GOTO_CALENDAR_TODAY';
+export const GOTO_CALENDAR_PREVIOUS_RANGE = 'GOTO_CALENDAR_PREVIOUS_RANGE';
+export const GOTO_CALENDAR_NEXT_RANGE = 'GOTO_CALENDAR_NEXT_RANGE';
+export const CLEAR_CALENDAR = 'CLEAR_CALENDAR';
+
+//
+// History
+
+export const FETCH_HISTORY = 'FETCH_HISTORY';
+export const GOTO_FIRST_HISTORY_PAGE = 'GOTO_FIRST_HISTORY_PAGE';
+export const GOTO_PREVIOUS_HISTORY_PAGE = 'GOTO_PREVIOUS_HISTORY_PAGE';
+export const GOTO_NEXT_HISTORY_PAGE = 'GOTO_NEXT_HISTORY_PAGE';
+export const GOTO_LAST_HISTORY_PAGE = 'GOTO_LAST_HISTORY_PAGE';
+export const GOTO_HISTORY_PAGE = 'GOTO_HISTORY_PAGE';
+export const SET_HISTORY_SORT = 'SET_HISTORY_SORT';
+export const SET_HISTORY_FILTER = 'SET_HISTORY_FILTER';
+export const SET_HISTORY_TABLE_OPTION = 'SET_HISTORY_TABLE_OPTION';
+export const CLEAR_HISTORY = 'CLEAR_HISTORY';
+
+export const MARK_AS_FAILED = 'MARK_AS_FAILED';
+
+//
+// Queue
+
+export const FETCH_QUEUE_STATUS = 'FETCH_QUEUE_STATUS';
+
+export const FETCH_QUEUE_DETAILS = 'FETCH_QUEUE_DETAILS';
+export const CLEAR_QUEUE_DETAILS = 'CLEAR_QUEUE_DETAILS';
+
+export const FETCH_QUEUE = 'FETCH_QUEUE';
+export const GOTO_FIRST_QUEUE_PAGE = 'GOTO_FIRST_QUEUE_PAGE';
+export const GOTO_PREVIOUS_QUEUE_PAGE = 'GOTO_PREVIOUS_QUEUE_PAGE';
+export const GOTO_NEXT_QUEUE_PAGE = 'GOTO_NEXT_QUEUE_PAGE';
+export const GOTO_LAST_QUEUE_PAGE = 'GOTO_LAST_QUEUE_PAGE';
+export const GOTO_QUEUE_PAGE = 'GOTO_QUEUE_PAGE';
+export const SET_QUEUE_SORT = 'SET_QUEUE_SORT';
+export const SET_QUEUE_TABLE_OPTION = 'SET_QUEUE_TABLE_OPTION';
+export const CLEAR_QUEUE = 'CLEAR_QUEUE';
+
+export const SET_QUEUE_EPISODES = 'SET_QUEUE_EPISODES';
+export const GRAB_QUEUE_ITEM = 'GRAB_QUEUE_ITEM';
+export const GRAB_QUEUE_ITEMS = 'GRAB_QUEUE_ITEMS';
+export const REMOVE_QUEUE_ITEM = 'REMOVE_QUEUE_ITEM';
+export const REMOVE_QUEUE_ITEMS = 'REMOVE_QUEUE_ITEMS';
+
+//
+// Blacklist
+
+export const FETCH_BLACKLIST = 'FETCH_BLACKLIST';
+export const GOTO_FIRST_BLACKLIST_PAGE = 'GOTO_FIRST_BLACKLIST_PAGE';
+export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'GOTO_PREVIOUS_BLACKLIST_PAGE';
+export const GOTO_NEXT_BLACKLIST_PAGE = 'GOTO_NEXT_BLACKLIST_PAGE';
+export const GOTO_LAST_BLACKLIST_PAGE = 'GOTO_LAST_BLACKLIST_PAGE';
+export const GOTO_BLACKLIST_PAGE = 'GOTO_BLACKLIST_PAGE';
+export const SET_BLACKLIST_SORT = 'SET_BLACKLIST_SORT';
+export const SET_BLACKLIST_TABLE_OPTION = 'SET_BLACKLIST_TABLE_OPTION';
+
+//
+// Wanted
+
+export const FETCH_MISSING = 'FETCH_MISSING';
+export const GOTO_FIRST_MISSING_PAGE = 'GOTO_FIRST_MISSING_PAGE';
+export const GOTO_PREVIOUS_MISSING_PAGE = 'GOTO_PREVIOUS_MISSING_PAGE';
+export const GOTO_NEXT_MISSING_PAGE = 'GOTO_NEXT_MISSING_PAGE';
+export const GOTO_LAST_MISSING_PAGE = 'GOTO_LAST_MISSING_PAGE';
+export const GOTO_MISSING_PAGE = 'GOTO_MISSING_PAGE';
+export const SET_MISSING_SORT = 'SET_MISSING_SORT';
+export const SET_MISSING_FILTER = 'SET_MISSING_FILTER';
+export const SET_MISSING_TABLE_OPTION = 'SET_MISSING_TABLE_OPTION';
+export const CLEAR_MISSING = 'CLEAR_MISSING';
+
+export const BATCH_TOGGLE_MISSING_EPISODES = 'BATCH_TOGGLE_MISSING_EPISODES';
+
+export const FETCH_CUTOFF_UNMET = 'FETCH_CUTOFF_UNMET';
+export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'GOTO_FIRST_CUTOFF_UNMET_PAGE';
+export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'GOTO_PREVIOUS_CUTOFF_UNMET_PAGE';
+export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'GOTO_NEXT_CUTOFF_UNMET_PAGE';
+export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'GOTO_LAST_CUTOFF_UNMET_PAGE';
+export const GOTO_CUTOFF_UNMET_PAGE = 'GOTO_CUTOFF_UNMET_PAGE';
+export const SET_CUTOFF_UNMET_SORT = 'SET_CUTOFF_UNMET_SORT';
+export const SET_CUTOFF_UNMET_FILTER = 'SET_CUTOFF_UNMET_FILTER';
+export const SET_CUTOFF_UNMET_TABLE_OPTION = 'SET_CUTOFF_UNMET_TABLE_OPTION';
+export const CLEAR_CUTOFF_UNMET = 'CLEAR_CUTOFF_UNMET';
+
+export const BATCH_TOGGLE_CUTOFF_UNMET_EPISODES = 'BATCH_TOGGLE_CUTOFF_UNMET_EPISODES';
+
+//
+// Settings
+
+export const TOGGLE_ADVANCED_SETTINGS = 'TOGGLE_ADVANCED_SETTINGS';
+
+export const FETCH_UI_SETTINGS = 'FETCH_UI_SETTINGS';
+export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE';
+export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS';
+
+export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'FETCH_MEDIA_MANAGEMENT_SETTINGS';
+export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'SET_MEDIA_MANAGEMENT_SETTINGS_VALUE';
+export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'SAVE_MEDIA_MANAGEMENT_SETTINGS';
+
+export const FETCH_NAMING_SETTINGS = 'FETCH_NAMING_SETTINGS';
+export const SET_NAMING_SETTINGS_VALUE = 'SET_NAMING_SETTINGS_VALUE';
+export const SAVE_NAMING_SETTINGS = 'SAVE_NAMING_SETTINGS';
+export const FETCH_NAMING_EXAMPLES = 'FETCH_NAMING_EXAMPLES';
+
+export const FETCH_QUALITY_PROFILES = 'FETCH_QUALITY_PROFILES';
+export const FETCH_QUALITY_PROFILE_SCHEMA = 'FETCH_QUALITY_PROFILE_SCHEMA';
+export const SET_QUALITY_PROFILE_VALUE = 'SET_QUALITY_PROFILE_VALUE';
+export const SAVE_QUALITY_PROFILE = 'SAVE_QUALITY_PROFILE';
+export const DELETE_QUALITY_PROFILE = 'DELETE_QUALITY_PROFILE';
+
+export const FETCH_LANGUAGE_PROFILES = 'FETCH_LANGUAGE_PROFILES';
+export const FETCH_LANGUAGE_PROFILE_SCHEMA = 'FETCH_LANGUAGE_PROFILE_SCHEMA';
+export const SET_LANGUAGE_PROFILE_VALUE = 'SET_LANGUAGE_PROFILE_VALUE';
+export const SAVE_LANGUAGE_PROFILE = 'SAVE_LANGUAGE_PROFILE';
+export const DELETE_LANGUAGE_PROFILE = 'DELETE_LANGUAGE_PROFILE';
+
+export const FETCH_DELAY_PROFILES = 'FETCH_DELAY_PROFILES';
+export const SET_DELAY_PROFILE_VALUE = 'SET_DELAY_PROFILE_VALUE';
+export const SAVE_DELAY_PROFILE = 'SAVE_DELAY_PROFILE';
+export const DELETE_DELAY_PROFILE = 'DELETE_DELAY_PROFILE';
+export const REORDER_DELAY_PROFILE = 'REORDER_DELAY_PROFILE';
+
+export const FETCH_QUALITY_DEFINITIONS = 'FETCH_QUALITY_DEFINITIONS';
+export const SET_QUALITY_DEFINITION_VALUE = 'SET_QUALITY_DEFINITION_VALUE';
+export const SAVE_QUALITY_DEFINITIONS = 'SAVE_QUALITY_DEFINITIONS';
+
+export const FETCH_INDEXERS = 'FETCH_INDEXERS';
+export const FETCH_INDEXER_SCHEMA = 'FETCH_INDEXER_SCHEMA';
+export const SELECT_INDEXER_SCHEMA = 'SELECT_INDEXER_SCHEMA';
+export const SET_INDEXER_VALUE = 'SET_INDEXER_VALUE';
+export const SET_INDEXER_FIELD_VALUE = 'SET_INDEXER_FIELD_VALUE';
+export const SAVE_INDEXER = 'SAVE_INDEXER';
+export const DELETE_INDEXER = 'DELETE_INDEXER';
+export const TEST_INDEXER = 'TEST_INDEXER';
+
+export const FETCH_INDEXER_OPTIONS = 'FETCH_INDEXER_OPTIONS';
+export const SET_INDEXER_OPTIONS_VALUE = 'SET_INDEXER_OPTIONS_VALUE';
+export const SAVE_INDEXER_OPTIONS = 'SAVE_INDEXER_OPTIONS';
+
+export const FETCH_RESTRICTIONS = 'FETCH_RESTRICTIONS';
+export const SET_RESTRICTION_VALUE = 'SET_RESTRICTION_VALUE';
+export const SAVE_RESTRICTION = 'SAVE_RESTRICTION';
+export const DELETE_RESTRICTION = 'DELETE_RESTRICTION';
+
+export const FETCH_DOWNLOAD_CLIENTS = 'FETCH_DOWNLOAD_CLIENTS';
+export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'FETCH_DOWNLOAD_CLIENT_SCHEMA';
+export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'SELECT_DOWNLOAD_CLIENT_SCHEMA';
+export const SET_DOWNLOAD_CLIENT_VALUE = 'SET_DOWNLOAD_CLIENT_VALUE';
+export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'SET_DOWNLOAD_CLIENT_FIELD_VALUE';
+export const SAVE_DOWNLOAD_CLIENT = 'SAVE_DOWNLOAD_CLIENT';
+export const DELETE_DOWNLOAD_CLIENT = 'DELETE_DOWNLOAD_CLIENT';
+export const TEST_DOWNLOAD_CLIENT = 'TEST_DOWNLOAD_CLIENT';
+
+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';
+
+export const FETCH_REMOTE_PATH_MAPPINGS = 'FETCH_REMOTE_PATH_MAPPINGS';
+export const SET_REMOTE_PATH_MAPPING_VALUE = 'SET_REMOTE_PATH_MAPPING_VALUE';
+export const SAVE_REMOTE_PATH_MAPPING = 'SAVE_REMOTE_PATH_MAPPING';
+export const DELETE_REMOTE_PATH_MAPPING = 'DELETE_REMOTE_PATH_MAPPING';
+
+export const FETCH_NOTIFICATIONS = 'FETCH_NOTIFICATIONS';
+export const FETCH_NOTIFICATION_SCHEMA = 'FETCH_NOTIFICATION_SCHEMA';
+export const SELECT_NOTIFICATION_SCHEMA = 'SELECT_NOTIFICATION_SCHEMA';
+export const SET_NOTIFICATION_VALUE = 'SET_NOTIFICATION_VALUE';
+export const SET_NOTIFICATION_FIELD_VALUE = 'SET_NOTIFICATION_FIELD_VALUE';
+export const SAVE_NOTIFICATION = 'SAVE_NOTIFICATION';
+export const DELETE_NOTIFICATION = 'DELETE_NOTIFICATION';
+export const TEST_NOTIFICATION = 'TEST_NOTIFICATION';
+
+export const FETCH_METADATA = 'FETCH_METADATA';
+export const SET_METADATA_VALUE = 'SET_METADATA_VALUE';
+export const SET_METADATA_FIELD_VALUE = 'SET_METADATA_FIELD_VALUE';
+export const SAVE_METADATA = 'SAVE_METADATA';
+
+//
+// System
+
+export const FETCH_STATUS = 'FETCH_STATUS';
+export const FETCH_HEALTH = 'FETCH_HEALTH';
+export const FETCH_DISK_SPACE = 'FETCH_DISK_SPACE';
+
+export const FETCH_TASK = 'FETCH_TASK';
+export const FETCH_TASKS = 'FETCH_TASKS';
+export const FETCH_BACKUPS = 'FETCH_BACKUPS';
+export const FETCH_UPDATES = 'FETCH_UPDATES';
+
+export const FETCH_LOGS = 'FETCH_LOGS';
+export const GOTO_FIRST_LOGS_PAGE = 'GOTO_FIRST_LOGS_PAGE';
+export const GOTO_PREVIOUS_LOGS_PAGE = 'GOTO_PREVIOUS_LOGS_PAGE';
+export const GOTO_NEXT_LOGS_PAGE = 'GOTO_NEXT_LOGS_PAGE';
+export const GOTO_LAST_LOGS_PAGE = 'GOTO_LAST_LOGS_PAGE';
+export const GOTO_LOGS_PAGE = 'GOTO_LOGS_PAGE';
+export const SET_LOGS_SORT = 'SET_LOGS_SORT';
+export const SET_LOGS_FILTER = 'SET_LOGS_FILTER';
+export const SET_LOGS_TABLE_OPTION = 'SET_LOGS_TABLE_OPTION';
+
+export const FETCH_LOG_FILES = 'FETCH_LOG_FILES';
+export const FETCH_UPDATE_LOG_FILES = 'FETCH_UPDATE_LOG_FILES';
+
+export const FETCH_GENERAL_SETTINGS = 'FETCH_GENERAL_SETTINGS';
+export const SET_GENERAL_SETTINGS_VALUE = 'SET_GENERAL_SETTINGS_VALUE';
+export const SAVE_GENERAL_SETTINGS = 'SAVE_GENERAL_SETTINGS';
+
+export const RESTART = 'RESTART';
+export const SHUTDOWN = 'SHUTDOWN';
+
+//
+// Commands
+
+export const FETCH_COMMANDS = 'FETCH_COMMANDS';
+export const EXECUTE_COMMAND = 'EXECUTE_COMMAND';
+export const ADD_COMMAND = 'ADD_COMMAND';
+export const UPDATE_COMMAND = 'UPDATE_COMMAND';
+export const FINISH_COMMAND = 'FINISH_COMMAND';
+export const REMOVE_COMMAND = 'REMOVE_COMMAND';
+export const REGISTER_FINISH_COMMAND_HANDLER = 'REGISTER_FINISH_COMMAND_HANDLER';
+export const UNREGISTER_FINISH_COMMAND_HANDLER = 'UNREGISTER_FINISH_COMMAND_HANDLER';
+
+//
+// Paths
+
+export const FETCH_PATHS = 'FETCH_PATHS';
+export const UPDATE_PATHS = 'UPDATE_PATHS';
+export const CLEAR_PATHS = 'CLEAR_PATHS';
+
+//
+// Languages
+
+export const FETCH_LANGUAGES = 'FETCH_LANGUAGES';
+
+//
+// Tags
+
+export const FETCH_TAGS = 'FETCH_TAGS';
+export const ADD_TAG = 'ADD_TAG';
+
+//
+// Captcha
+
+export const REFRESH_CAPTCHA = 'REFRESH_CAPTCHA';
+export const GET_CAPTCHA_COOKIE = 'GET_CAPTCHA_COOKIE';
+export const SET_CAPTCHA_VALUE = 'SET_CAPTCHA_VALUE';
+export const RESET_CAPTCHA = 'RESET_CAPTCHA';
+
+//
+// OAuth
+
+export const START_OAUTH = 'START_OAUTH';
+export const GET_OAUTH_TOKEN = 'GET_OAUTH_TOKEN';
+export const SET_OAUTH_VALUE = 'SET_OAUTH_VALUE';
+export const RESET_OAUTH = 'RESET_OAUTH';
+
+//
+// Interactive Import
+
+export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'FETCH_INTERACTIVE_IMPORT_ITEMS';
+export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'UPDATE_INTERACTIVE_IMPORT_ITEM';
+export const SET_INTERACTIVE_IMPORT_SORT = 'SET_INTERACTIVE_IMPORT_SORT';
+export const CLEAR_INTERACTIVE_IMPORT = 'CLEAR_INTERACTIVE_IMPORT';
+export const ADD_RECENT_FOLDER = 'ADD_RECENT_FOLDER';
+export const REMOVE_RECENT_FOLDER = 'REMOVE_RECENT_FOLDER';
+export const SET_INTERACTIVE_IMPORT_MODE = 'SET_INTERACTIVE_IMPORT_MODE';
+
+//
+// Root Folders
+
+export const FETCH_ROOT_FOLDERS = 'FETCH_ROOT_FOLDERS';
+export const ADD_ROOT_FOLDER = 'ADD_ROOT_FOLDER';
+export const DELETE_ROOT_FOLDER = 'DELETE_ROOT_FOLDER';
+
+//
+// Organize Preview
+
+export const FETCH_ORGANIZE_PREVIEW = 'FETCH_ORGANIZE_PREVIEW';
+export const CLEAR_ORGANIZE_PREVIEW = 'CLEAR_ORGANIZE_PREVIEW';
diff --git a/frontend/src/Store/Actions/addSeriesActionHandlers.js b/frontend/src/Store/Actions/addSeriesActionHandlers.js
new file mode 100644
index 000000000..27d08a8a6
--- /dev/null
+++ b/frontend/src/Store/Actions/addSeriesActionHandlers.js
@@ -0,0 +1,98 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import getNewSeries from 'Utilities/Series/getNewSeries';
+import * as types from './actionTypes';
+import { set, update, updateItem } from './baseActions';
+
+let currentXHR = null;
+let xhrCancelled = false;
+const section = 'addSeries';
+
+const addSeriesActionHandlers = {
+ [types.LOOKUP_SERIES]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ if (currentXHR) {
+ xhrCancelled = true;
+ currentXHR.abort();
+ currentXHR = null;
+ }
+
+ currentXHR = new window.XMLHttpRequest();
+ xhrCancelled = false;
+
+ const promise = $.ajax({
+ url: '/artist/lookup',
+ xhr: () => currentXHR,
+ data: {
+ term: payload.term
+ }
+ });
+
+ 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: xhrCancelled ? null : xhr
+ }));
+ });
+ };
+ },
+
+ [types.ADD_SERIES]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isAdding: true }));
+
+ const foreignArtistId = payload.foreignArtistId;
+ const items = getState().addSeries.items;
+ const newSeries = getNewSeries(_.cloneDeep(_.find(items, { foreignArtistId })), payload);
+
+ const promise = $.ajax({
+ url: '/artist',
+ 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
+ }));
+ });
+ };
+ }
+};
+
+export default addSeriesActionHandlers;
diff --git a/frontend/src/Store/Actions/addSeriesActions.js b/frontend/src/Store/Actions/addSeriesActions.js
new file mode 100644
index 000000000..aeb2c1db2
--- /dev/null
+++ b/frontend/src/Store/Actions/addSeriesActions.js
@@ -0,0 +1,15 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import addSeriesActionHandlers from './addSeriesActionHandlers';
+
+export const lookupSeries = addSeriesActionHandlers[types.LOOKUP_SERIES];
+export const addSeries = addSeriesActionHandlers[types.ADD_SERIES];
+export const clearAddSeries = createAction(types.CLEAR_ADD_SERIES);
+export const setAddSeriesDefault = createAction(types.SET_ADD_SERIES_DEFAULT);
+
+export const setAddSeriesValue = createAction(types.SET_ADD_SERIES_VALUE, (payload) => {
+ return {
+ section: 'addSeries',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js
new file mode 100644
index 000000000..3d0191a21
--- /dev/null
+++ b/frontend/src/Store/Actions/appActions.js
@@ -0,0 +1,27 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+
+export const saveDimensions = createAction(types.SAVE_DIMENSIONS);
+export const setVersion = createAction(types.SET_VERSION);
+export const setIsSidebarVisible = createAction(types.SET_IS_SIDEBAR_VISIBLE);
+
+export const setAppValue = createAction(types.SET_APP_VALUE, (payload) => {
+ return {
+ section: 'app',
+ ...payload
+ };
+});
+
+export const showMessage = createAction(types.SHOW_MESSAGE, (payload) => {
+ return {
+ section: 'messages',
+ ...payload
+ };
+});
+
+export const hideMessage = createAction(types.HIDE_MESSAGE, (payload) => {
+ return {
+ section: 'messages',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/artistActionHandlers.js b/frontend/src/Store/Actions/artistActionHandlers.js
new file mode 100644
index 000000000..6e4f1d231
--- /dev/null
+++ b/frontend/src/Store/Actions/artistActionHandlers.js
@@ -0,0 +1,130 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { updateItem } from './baseActions';
+
+const section = 'series';
+
+const artistActionHandlers = {
+ [types.FETCH_ARTIST]: createFetchHandler(section, '/artist'),
+
+ [types.SAVE_ARTIST]: createSaveProviderHandler(section,
+ '/artist',
+ (state) => state.series),
+
+ [types.DELETE_ARTIST]: createRemoveItemHandler(section,
+ '/artist',
+ (state) => state.series),
+
+ [types.TOGGLE_ARTIST_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ artistId: id,
+ monitored
+ } = payload;
+
+ const series = _.find(getState().series.items, { id });
+
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: `/artist/${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
+ }));
+ });
+ };
+ },
+
+ [types.TOGGLE_ALBUM_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ artistId: id,
+ seasonNumber,
+ monitored
+ } = payload;
+
+ const series = _.find(getState().series.items, { id });
+ const seasons = _.cloneDeep(series.seasons);
+ const season = _.find(seasons, { seasonNumber });
+
+ season.isSaving = true;
+
+ dispatch(updateItem({
+ id,
+ section,
+ seasons
+ }));
+
+ season.monitored = monitored;
+
+ const promise = $.ajax({
+ url: `/artist/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...series,
+ seasons
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ const episodes = _.filter(getState().episodes.items, { artistId: id, seasonNumber });
+
+ dispatch(batchActions([
+ updateItem({
+ id,
+ section,
+ ...data
+ }),
+
+ ...episodes.map((episode) => {
+ return updateItem({
+ id: episode.id,
+ section: 'episodes',
+ monitored
+ });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ seasons: series.seasons
+ }));
+ });
+ };
+ }
+};
+
+export default artistActionHandlers;
diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js
new file mode 100644
index 000000000..0f7ba43bf
--- /dev/null
+++ b/frontend/src/Store/Actions/artistIndexActions.js
@@ -0,0 +1,8 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+
+export const setArtistSort = createAction(types.SET_ARTIST_SORT);
+export const setArtistFilter = createAction(types.SET_ARTIST_FILTER);
+export const setArtistView = createAction(types.SET_ARTIST_VIEW);
+export const setArtistTableOption = createAction(types.SET_ARTIST_TABLE_OPTION);
+export const setArtistPosterOption = createAction(types.SET_ARTIST_POSTER_OPTION);
diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js
new file mode 100644
index 000000000..e2d7e9d7e
--- /dev/null
+++ b/frontend/src/Store/Actions/baseActions.js
@@ -0,0 +1,13 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+
+export const set = createAction(types.SET);
+
+export const update = createAction(types.UPDATE);
+export const updateItem = createAction(types.UPDATE_ITEM);
+export const updateServerSideCollection = createAction(types.UPDATE_SERVER_SIDE_COLLECTION);
+
+export const setSettingValue = createAction(types.SET_SETTING_VALUE);
+export const clearPendingChanges = createAction(types.CLEAR_PENDING_CHANGES);
+
+export const removeItem = createAction(types.REMOVE_ITEM);
diff --git a/frontend/src/Store/Actions/blacklistActionHandlers.js b/frontend/src/Store/Actions/blacklistActionHandlers.js
new file mode 100644
index 000000000..0a4e4a1f6
--- /dev/null
+++ b/frontend/src/Store/Actions/blacklistActionHandlers.js
@@ -0,0 +1,17 @@
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import * as types from './actionTypes';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+
+const blacklistActionHandlers = {
+ ...createServerSideCollectionHandlers('blacklist', '/blacklist', (state) => state, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_BLACKLIST,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_BLACKLIST_SORT
+ })
+};
+
+export default blacklistActionHandlers;
diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js
new file mode 100644
index 000000000..d931842d1
--- /dev/null
+++ b/frontend/src/Store/Actions/blacklistActions.js
@@ -0,0 +1,12 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import blacklistActionHandlers from './blacklistActionHandlers';
+
+export const fetchBlacklist = blacklistActionHandlers[types.FETCH_BLACKLIST];
+export const gotoBlacklistFirstPage = blacklistActionHandlers[types.GOTO_FIRST_BLACKLIST_PAGE];
+export const gotoBlacklistPreviousPage = blacklistActionHandlers[types.GOTO_PREVIOUS_BLACKLIST_PAGE];
+export const gotoBlacklistNextPage = blacklistActionHandlers[types.GOTO_NEXT_BLACKLIST_PAGE];
+export const gotoBlacklistLastPage = blacklistActionHandlers[types.GOTO_LAST_BLACKLIST_PAGE];
+export const gotoBlacklistPage = blacklistActionHandlers[types.GOTO_BLACKLIST_PAGE];
+export const setBlacklistSort = blacklistActionHandlers[types.SET_BLACKLIST_SORT];
+export const setBlacklistTableOption = createAction(types.SET_BLACKLIST_TABLE_OPTION);
diff --git a/frontend/src/Store/Actions/calendarActionHandlers.js b/frontend/src/Store/Actions/calendarActionHandlers.js
new file mode 100644
index 000000000..2054a64a0
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActionHandlers.js
@@ -0,0 +1,264 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import moment from 'moment';
+import { batchActions } from 'redux-batched-actions';
+import * as calendarViews from 'Calendar/calendarViews';
+import * as types from './actionTypes';
+import { set, update } from './baseActions';
+import { fetchCalendar } from './calendarActions';
+
+const viewRanges = {
+ [calendarViews.DAY]: 'day',
+ [calendarViews.WEEK]: 'week',
+ [calendarViews.MONTH]: 'month',
+ [calendarViews.FORECAST]: 'day'
+};
+
+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;
+}
+
+const section = 'calendar';
+
+const calendarActionHandlers = {
+ [types.FETCH_CALENDAR]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+ const unmonitored = state.calendar.unmonitored;
+
+ const {
+ time,
+ 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
+ }));
+ });
+ };
+ },
+
+ [types.SET_CALENDAR_DAYS_COUNT]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({
+ section,
+ dayCount: payload.dayCount
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({
+ time,
+ view
+ }));
+ };
+ },
+
+ [types.SET_CALENDAR_INCLUDE_UNMONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({
+ section,
+ unmonitored: payload.unmonitored
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({
+ time,
+ view
+ }));
+ };
+ },
+
+ [types.SET_CALENDAR_VIEW]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+ const view = payload.view;
+ const time = view === calendarViews.FORECAST ? moment() : state.calendar.time;
+
+ dispatch(fetchCalendar({ time, view }));
+ };
+ },
+
+ [types.GOTO_CALENDAR_TODAY]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+ const view = state.calendar.view;
+ const time = moment();
+
+ dispatch(fetchCalendar({ time, view }));
+ };
+ },
+
+ [types.GOTO_CALENDAR_PREVIOUS_RANGE]: function(payload) {
+ return function(dispatch, getState) {
+ 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 }));
+ };
+ },
+
+ [types.GOTO_CALENDAR_NEXT_RANGE]: function(payload) {
+ return function(dispatch, getState) {
+ 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 }));
+ };
+ }
+};
+
+export default calendarActionHandlers;
diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js
new file mode 100644
index 000000000..452769343
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActions.js
@@ -0,0 +1,12 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import calendarActionHandlers from './calendarActionHandlers';
+
+export const fetchCalendar = calendarActionHandlers[types.FETCH_CALENDAR];
+export const setCalendarDaysCount = calendarActionHandlers[types.SET_CALENDAR_DAYS_COUNT];
+export const setCalendarIncludeUnmonitored = calendarActionHandlers[types.SET_CALENDAR_INCLUDE_UNMONITORED];
+export const setCalendarView = calendarActionHandlers[types.SET_CALENDAR_VIEW];
+export const gotoCalendarToday = calendarActionHandlers[types.GOTO_CALENDAR_TODAY];
+export const gotoCalendarPreviousRange = calendarActionHandlers[types.GOTO_CALENDAR_PREVIOUS_RANGE];
+export const gotoCalendarNextRange = calendarActionHandlers[types.GOTO_CALENDAR_NEXT_RANGE];
+export const clearCalendar = createAction(types.CLEAR_CALENDAR);
diff --git a/frontend/src/Store/Actions/captchaActionHandlers.js b/frontend/src/Store/Actions/captchaActionHandlers.js
new file mode 100644
index 000000000..6e00a840e
--- /dev/null
+++ b/frontend/src/Store/Actions/captchaActionHandlers.js
@@ -0,0 +1,67 @@
+import requestAction from 'Utilities/requestAction';
+import * as types from './actionTypes';
+import { setCaptchaValue } from './captchaActions';
+
+const captchaActionHandlers = {
+ [types.REFRESH_CAPTCHA]: function(payload) {
+ return (dispatch, getState) => {
+ 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
+ }));
+ });
+ };
+ },
+
+ [types.GET_CAPTCHA_COOKIE]: function(payload) {
+ return (dispatch, getState) => {
+ 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
+ }));
+ });
+ };
+ }
+};
+
+export default captchaActionHandlers;
diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js
new file mode 100644
index 000000000..e87a2a088
--- /dev/null
+++ b/frontend/src/Store/Actions/captchaActions.js
@@ -0,0 +1,8 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import captchaActionHandlers from './captchaActionHandlers';
+
+export const refreshCaptcha = captchaActionHandlers[types.REFRESH_CAPTCHA];
+export const getCaptchaCookie = captchaActionHandlers[types.GET_CAPTCHA_COOKIE];
+export const setCaptchaValue = createAction(types.SET_CAPTCHA_VALUE);
+export const resetCaptcha = createAction(types.RESET_CAPTCHA);
diff --git a/frontend/src/Store/Actions/commandActionHandlers.js b/frontend/src/Store/Actions/commandActionHandlers.js
new file mode 100644
index 000000000..e0f8baea0
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActionHandlers.js
@@ -0,0 +1,141 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { isSameCommand } from 'Utilities/Command';
+import { messageTypes } from 'Helpers/Props';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import { showMessage, hideMessage } from './appActions';
+import { updateItem } from './baseActions';
+import { addCommand, removeCommand } from './commandActions';
+
+let lastCommand = null;
+let lastCommandTimeout = null;
+const removeCommandTimeoutIds = {};
+
+function showCommandMessage(payload, dispatch) {
+ const {
+ id,
+ name,
+ manual,
+ message,
+ body = {},
+ state
+ } = payload;
+
+ const {
+ sendUpdatesToClient,
+ suppressMessages
+ } = body;
+
+ if (!message || !body || !sendUpdatesToClient || suppressMessages) {
+ return;
+ }
+
+ let type = messageTypes.INFO;
+ let hideAfter = 0;
+
+ if (state === 'completed') {
+ type = messageTypes.SUCCESS;
+ hideAfter = 4;
+ } else if (state === 'failed') {
+ type = messageTypes.ERROR;
+ hideAfter = manual ? 10 : 4;
+ }
+
+ dispatch(showMessage({
+ id,
+ name,
+ message,
+ type,
+ hideAfter
+ }));
+}
+
+function scheduleRemoveCommand(command, dispatch) {
+ const {
+ id,
+ state
+ } = command;
+
+ if (state === 'queued') {
+ return;
+ }
+
+ const timeoutId = removeCommandTimeoutIds[id];
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ removeCommandTimeoutIds[id] = setTimeout(() => {
+ dispatch(batchActions([
+ removeCommand({ section: 'commands', id }),
+ hideMessage({ id })
+ ]));
+
+ delete removeCommandTimeoutIds[id];
+ }, 30000);
+}
+
+const commandActionHandlers = {
+ [types.FETCH_COMMANDS]: createFetchHandler('commands', '/command'),
+
+ [types.EXECUTE_COMMAND](payload) {
+ return (dispatch, getState) => {
+ // 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)
+ });
+
+ promise.done((data) => {
+ dispatch(addCommand(data));
+ });
+ };
+ },
+
+ [types.UPDATE_COMMAND](payload) {
+ return (dispatch, getState) => {
+ dispatch(updateItem({ section: 'commands', ...payload }));
+
+ showCommandMessage(payload, dispatch);
+ scheduleRemoveCommand(payload, dispatch);
+ };
+ },
+
+ [types.FINISH_COMMAND](payload) {
+ return (dispatch, getState) => {
+ 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(removeCommand({ section: 'commands', ...payload }));
+ showCommandMessage(payload, dispatch);
+ };
+ }
+
+};
+
+export default commandActionHandlers;
diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js
new file mode 100644
index 000000000..84b6d4fdb
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActions.js
@@ -0,0 +1,14 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import commandActionHandlers from './commandActionHandlers';
+
+export const fetchCommands = commandActionHandlers[types.FETCH_COMMANDS];
+export const executeCommand = commandActionHandlers[types.EXECUTE_COMMAND];
+export const updateCommand = commandActionHandlers[types.UPDATE_COMMAND];
+export const finishCommand = commandActionHandlers[types.FINISH_COMMAND];
+
+export const addCommand = createAction(types.ADD_COMMAND);
+export const removeCommand = createAction(types.REMOVE_COMMAND);
+
+export const registerFinishCommandHandler = createAction(types.REGISTER_FINISH_COMMAND_HANDLER);
+export const unregisterFinishCommandHandler = createAction(types.UNREGISTER_FINISH_COMMAND_HANDLER);
diff --git a/frontend/src/Store/Actions/episodeActionHandlers.js b/frontend/src/Store/Actions/episodeActionHandlers.js
new file mode 100644
index 000000000..2d2c3ed65
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeActionHandlers.js
@@ -0,0 +1,111 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import episodeEntities from 'Episode/episodeEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+import { updateItem } from './baseActions';
+
+const section = 'episodes';
+
+const episodeActionHandlers = {
+ [types.FETCH_EPISODES]: createFetchHandler(section, '/episode'),
+
+ [types.TOGGLE_EPISODE_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ episodeId: id,
+ episodeEntity = episodeEntities.EPISODES,
+ monitored
+ } = payload;
+
+ const episodeSection = _.last(episodeEntity.split('.'));
+
+ dispatch(updateItem({
+ id,
+ section: episodeSection,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: `/episode/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({ monitored }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id,
+ section: episodeSection,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section: episodeSection,
+ isSaving: false
+ }));
+ });
+ };
+ },
+
+ [types.TOGGLE_EPISODES_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ });
+ })
+ ));
+ });
+ };
+ }
+};
+
+export default episodeActionHandlers;
diff --git a/frontend/src/Store/Actions/episodeActions.js b/frontend/src/Store/Actions/episodeActions.js
new file mode 100644
index 000000000..b0abe85eb
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeActions.js
@@ -0,0 +1,10 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import episodeActionHandlers from './episodeActionHandlers';
+
+export const fetchEpisodes = episodeActionHandlers[types.FETCH_EPISODES];
+export const setEpisodesSort = createAction(types.SET_EPISODES_SORT);
+export const setEpisodesTableOption = createAction(types.SET_EPISODES_TABLE_OPTION);
+export const clearEpisodes = createAction(types.CLEAR_EPISODES);
+export const toggleEpisodeMonitored = episodeActionHandlers[types.TOGGLE_EPISODE_MONITORED];
+export const toggleEpisodesMonitored = episodeActionHandlers[types.TOGGLE_EPISODES_MONITORED];
diff --git a/frontend/src/Store/Actions/episodeFileActionHandlers.js b/frontend/src/Store/Actions/episodeFileActionHandlers.js
new file mode 100644
index 000000000..a0a661f03
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeFileActionHandlers.js
@@ -0,0 +1,164 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import episodeEntities from 'Episode/episodeEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import * as types from './actionTypes';
+import { set, removeItem, updateItem } from './baseActions';
+
+const section = 'episodeFiles';
+const deleteEpisodeFile = createRemoveItemHandler(section, '/episodeFile');
+
+const episodeFileActionHandlers = {
+ [types.FETCH_EPISODE_FILES]: createFetchHandler(section, '/episodeFile'),
+
+ [types.DELETE_EPISODE_FILE]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ id: episodeFileId,
+ episodeEntity = episodeEntities.EPISODES
+ } = payload;
+
+ const episodeSection = _.last(episodeEntity.split('.'));
+
+ const deletePromise = deleteEpisodeFile(payload)(dispatch, getState);
+
+ 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
+ });
+ })
+ ]));
+ });
+ };
+ },
+
+ [types.DELETE_EPISODE_FILES]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ },
+
+ [types.UPDATE_EPISODE_FILES]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ }
+};
+
+export default episodeFileActionHandlers;
diff --git a/frontend/src/Store/Actions/episodeFileActions.js b/frontend/src/Store/Actions/episodeFileActions.js
new file mode 100644
index 000000000..f8087afa8
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeFileActions.js
@@ -0,0 +1,9 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import episodeFileActionHandlers from './episodeFileActionHandlers';
+
+export const fetchEpisodeFiles = episodeFileActionHandlers[types.FETCH_EPISODE_FILES];
+export const deleteEpisodeFile = episodeFileActionHandlers[types.DELETE_EPISODE_FILE];
+export const deleteEpisodeFiles = episodeFileActionHandlers[types.DELETE_EPISODE_FILES];
+export const updateEpisodeFiles = episodeFileActionHandlers[types.UPDATE_EPISODE_FILES];
+export const clearEpisodeFiles = createAction(types.CLEAR_EPISODE_FILES);
diff --git a/frontend/src/Store/Actions/episodeHistoryActionHandlers.js b/frontend/src/Store/Actions/episodeHistoryActionHandlers.js
new file mode 100644
index 000000000..530eade2b
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeHistoryActionHandlers.js
@@ -0,0 +1,75 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { sortDirections } from 'Helpers/Props';
+import * as types from './actionTypes';
+import { set, update } from './baseActions';
+import { fetchEpisodeHistory } from './episodeHistoryActions';
+
+const episodeHistoryActionHandlers = {
+ [types.FETCH_EPISODE_HISTORY]: function(payload) {
+ const section = 'episodeHistory';
+
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ const queryParams = {
+ pageSize: 1000,
+ page: 1,
+ filterKey: 'episodeId',
+ filterValue: payload.episodeId,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING
+ };
+
+ 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
+ }));
+ });
+ };
+ },
+
+ [types.EPISODE_HISTORY_MARK_AS_FAILED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ historyId,
+ episodeId
+ } = payload;
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id: historyId
+ }
+ });
+
+ promise.done(() => {
+ dispatch(fetchEpisodeHistory({ episodeId }));
+ });
+ };
+ }
+};
+
+export default episodeHistoryActionHandlers;
diff --git a/frontend/src/Store/Actions/episodeHistoryActions.js b/frontend/src/Store/Actions/episodeHistoryActions.js
new file mode 100644
index 000000000..6c0c90770
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeHistoryActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import episodeHistoryActionHandlers from './episodeHistoryActionHandlers';
+
+export const fetchEpisodeHistory = episodeHistoryActionHandlers[types.FETCH_EPISODE_HISTORY];
+export const clearEpisodeHistory = createAction(types.CLEAR_EPISODE_HISTORY);
+export const episodeHistoryMarkAsFailed = episodeHistoryActionHandlers[types.EPISODE_HISTORY_MARK_AS_FAILED];
diff --git a/frontend/src/Store/Actions/historyActionHandlers.js b/frontend/src/Store/Actions/historyActionHandlers.js
new file mode 100644
index 000000000..44cff0fc3
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActionHandlers.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import * as types from './actionTypes';
+import { updateItem } from './baseActions';
+
+const section = 'history';
+
+const historyActionHandlers = {
+ ...createServerSideCollectionHandlers('history', '/history', (state) => state.history, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_HISTORY,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_HISTORY_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_HISTORY_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_HISTORY_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_HISTORY_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_HISTORY_FILTER
+ }),
+
+ [types.MARK_AS_FAILED]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ }
+};
+
+export default historyActionHandlers;
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
new file mode 100644
index 000000000..d4fe71be9
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -0,0 +1,16 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import historyActionHandlers from './historyActionHandlers';
+
+export const fetchHistory = historyActionHandlers[types.FETCH_HISTORY];
+export const gotoHistoryFirstPage = historyActionHandlers[types.GOTO_FIRST_HISTORY_PAGE];
+export const gotoHistoryPreviousPage = historyActionHandlers[types.GOTO_PREVIOUS_HISTORY_PAGE];
+export const gotoHistoryNextPage = historyActionHandlers[types.GOTO_NEXT_HISTORY_PAGE];
+export const gotoHistoryLastPage = historyActionHandlers[types.GOTO_LAST_HISTORY_PAGE];
+export const gotoHistoryPage = historyActionHandlers[types.GOTO_HISTORY_PAGE];
+export const setHistorySort = historyActionHandlers[types.SET_HISTORY_SORT];
+export const setHistoryFilter = historyActionHandlers[types.SET_HISTORY_FILTER];
+export const setHistoryTableOption = createAction(types.SET_HISTORY_TABLE_OPTION);
+export const clearHistory = createAction(types.CLEAR_HISTORY);
+
+export const markAsFailed = historyActionHandlers[types.MARK_AS_FAILED];
diff --git a/frontend/src/Store/Actions/importSeriesActionHandlers.js b/frontend/src/Store/Actions/importSeriesActionHandlers.js
new file mode 100644
index 000000000..bbf3523dc
--- /dev/null
+++ b/frontend/src/Store/Actions/importSeriesActionHandlers.js
@@ -0,0 +1,172 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import getNewSeries from 'Utilities/Series/getNewSeries';
+import * as types from './actionTypes';
+import { set, updateItem, removeItem } from './baseActions';
+import { startLookupSeries } from './importSeriesActions';
+import { fetchRootFolders } from './rootFolderActions';
+
+const section = 'importSeries';
+let concurrentLookups = 0;
+
+const importSeriesActionHandlers = {
+ [types.QUEUE_LOOKUP_SERIES]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ name,
+ path,
+ term
+ } = 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,
+ queued: true,
+ items: []
+ }));
+
+ if (term && term.length > 2) {
+ dispatch(startLookupSeries());
+ }
+ };
+ },
+
+ [types.START_LOOKUP_SERIES]: function(payload) {
+ return function(dispatch, getState) {
+ if (concurrentLookups >= 1) {
+ return;
+ }
+
+ const state = getState().importSeries;
+ const queued = _.find(state.items, { queued: true });
+
+ if (!queued) {
+ return;
+ }
+
+ concurrentLookups++;
+
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: true
+ }));
+
+ const promise = $.ajax({
+ url: '/series/lookup',
+ data: {
+ term: queued.term
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data,
+ queued: false,
+ selectedSeries: queued.selectedSeries || data[0]
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr,
+ queued: false
+ }));
+ });
+
+ promise.always(() => {
+ concurrentLookups--;
+ dispatch(startLookupSeries());
+ });
+ };
+ },
+
+ [types.IMPORT_SERIES]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }))
+ ));
+ });
+ };
+ }
+};
+
+export default importSeriesActionHandlers;
diff --git a/frontend/src/Store/Actions/importSeriesActions.js b/frontend/src/Store/Actions/importSeriesActions.js
new file mode 100644
index 000000000..6b119368b
--- /dev/null
+++ b/frontend/src/Store/Actions/importSeriesActions.js
@@ -0,0 +1,16 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import importSeriesActionHandlers from './importSeriesActionHandlers';
+
+export const queueLookupSeries = importSeriesActionHandlers[types.QUEUE_LOOKUP_SERIES];
+export const startLookupSeries = importSeriesActionHandlers[types.START_LOOKUP_SERIES];
+export const importSeries = importSeriesActionHandlers[types.IMPORT_SERIES];
+export const clearImportSeries = createAction(types.CLEAR_IMPORT_SERIES);
+
+export const setImportSeriesValue = createAction(types.SET_IMPORT_SERIES_VALUE, (payload) => {
+ return {
+
+ section: 'importSeries',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/interactiveImportActionHandlers.js b/frontend/src/Store/Actions/interactiveImportActionHandlers.js
new file mode 100644
index 000000000..a46ce5702
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActionHandlers.js
@@ -0,0 +1,48 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import { set, update } from './baseActions';
+
+const section = 'interactiveImport';
+
+const interactiveImportActionHandlers = {
+ [types.FETCH_INTERACTIVE_IMPORT_ITEMS]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ }
+};
+
+export default interactiveImportActionHandlers;
diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js
new file mode 100644
index 000000000..880cd0ade
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActions.js
@@ -0,0 +1,11 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import interactiveImportActionHandlers from './interactiveImportActionHandlers';
+
+export const fetchInteractiveImportItems = interactiveImportActionHandlers[types.FETCH_INTERACTIVE_IMPORT_ITEMS];
+export const setInteractiveImportSort = createAction(types.SET_INTERACTIVE_IMPORT_SORT);
+export const updateInteractiveImportItem = createAction(types.UPDATE_INTERACTIVE_IMPORT_ITEM);
+export const clearInteractiveImport = createAction(types.CLEAR_INTERACTIVE_IMPORT);
+export const addRecentFolder = createAction(types.ADD_RECENT_FOLDER);
+export const removeRecentFolder = createAction(types.REMOVE_RECENT_FOLDER);
+export const setInteractiveImportMode = createAction(types.SET_INTERACTIVE_IMPORT_MODE);
diff --git a/frontend/src/Store/Actions/oAuthActionHandlers.js b/frontend/src/Store/Actions/oAuthActionHandlers.js
new file mode 100644
index 000000000..f65584015
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActionHandlers.js
@@ -0,0 +1,80 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import requestAction from 'Utilities/requestAction';
+import * as types from './actionTypes';
+import { setOAuthValue } from './oAuthActions';
+
+function showOAuthWindow(url) {
+ const deferred = $.Deferred();
+ const selfWindow = window;
+
+ window.open(url);
+
+ selfWindow.onCompleteOauth = function(query, callback) {
+ delete selfWindow.onCompleteOauth;
+
+ const queryParams = {};
+ const splitQuery = query.substring(1).split('&');
+
+ _.each(splitQuery, (param) => {
+ const paramSplit = param.split('=');
+
+ queryParams[paramSplit[0]] = paramSplit[1];
+ });
+
+ callback();
+ deferred.resolve(queryParams);
+ };
+
+ return deferred.promise();
+}
+
+const oAuthActionHandlers = {
+
+ [types.START_OAUTH]: function(payload) {
+ return (dispatch, getState) => {
+ const actionPayload = {
+ action: 'startOAuth',
+ queryParams: { callbackUrl: `${window.location.origin}/oauth.html` },
+ ...payload
+ };
+
+ dispatch(setOAuthValue({
+ authorizing: true
+ }));
+
+ const promise = requestAction(actionPayload)
+ .then((response) => {
+ return showOAuthWindow(response.oauthUrl);
+ })
+ .then((queryParams) => {
+ return requestAction({
+ action: 'getOAuthToken',
+ queryParams,
+ ...payload
+ });
+ })
+ .then((response) => {
+ const {
+ accessToken,
+ accessTokenSecret
+ } = response;
+
+ dispatch(setOAuthValue({
+ authorizing: false,
+ accessToken,
+ accessTokenSecret
+ }));
+ });
+
+ promise.fail(() => {
+ dispatch(setOAuthValue({
+ authorizing: false
+ }));
+ });
+ };
+ }
+
+};
+
+export default oAuthActionHandlers;
diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js
new file mode 100644
index 000000000..93de6f11f
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import oAuthActionHandlers from './oAuthActionHandlers';
+
+export const startOAuth = oAuthActionHandlers[types.START_OAUTH];
+export const setOAuthValue = createAction(types.SET_OAUTH_VALUE);
+export const resetOAuth = createAction(types.RESET_OAUTH);
diff --git a/frontend/src/Store/Actions/organizePreviewActionHandlers.js b/frontend/src/Store/Actions/organizePreviewActionHandlers.js
new file mode 100644
index 000000000..d0901b5ea
--- /dev/null
+++ b/frontend/src/Store/Actions/organizePreviewActionHandlers.js
@@ -0,0 +1,8 @@
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+
+const organizePreviewActionHandlers = {
+ [types.FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename')
+};
+
+export default organizePreviewActionHandlers;
diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js
new file mode 100644
index 000000000..602028ff4
--- /dev/null
+++ b/frontend/src/Store/Actions/organizePreviewActions.js
@@ -0,0 +1,6 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import organizePreviewActionHandlers from './organizePreviewActionHandlers';
+
+export const fetchOrganizePreview = organizePreviewActionHandlers[types.FETCH_ORGANIZE_PREVIEW];
+export const clearOrganizePreview = createAction(types.CLEAR_ORGANIZE_PREVIEW);
diff --git a/frontend/src/Store/Actions/pathActionHandlers.js b/frontend/src/Store/Actions/pathActionHandlers.js
new file mode 100644
index 000000000..11ebfaf21
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActionHandlers.js
@@ -0,0 +1,43 @@
+import $ from 'jquery';
+import * as types from './actionTypes';
+import { set } from './baseActions';
+import { updatePaths } from './pathActions';
+
+const section = 'paths';
+
+const pathActionHandlers = {
+ [types.FETCH_PATHS](payload) {
+ return (dispatch, getState) => {
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = $.ajax({
+ url: '/filesystem',
+ data: {
+ path: payload.path
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(updatePaths({ path: payload.path, ...data }));
+
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default pathActionHandlers;
diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js
new file mode 100644
index 000000000..fd4e76f02
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import pathActionHandlers from './pathActionHandlers';
+
+export const fetchPaths = pathActionHandlers[types.FETCH_PATHS];
+export const updatePaths = createAction(types.UPDATE_PATHS);
+export const clearPaths = createAction(types.CLEAR_PATHS);
diff --git a/frontend/src/Store/Actions/queueActionHandlers.js b/frontend/src/Store/Actions/queueActionHandlers.js
new file mode 100644
index 000000000..b5b44e8ec
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActionHandlers.js
@@ -0,0 +1,230 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import createFetchHandler from './Creators/createFetchHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import * as types from './actionTypes';
+import { set, updateItem } from './baseActions';
+import { fetchQueue } from './queueActions';
+
+const fetchQueueDetailsHandler = createFetchHandler('details', '/queue/details');
+
+const queueActionHandlers = {
+ [types.FETCH_QUEUE_STATUS]: createFetchHandler('queueStatus', '/queue/status'),
+
+ [types.FETCH_QUEUE_DETAILS]: function(payload) {
+ return function(dispatch, getState) {
+ 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)) {
+ const fetchFunction = fetchQueueDetailsHandler(params);
+ fetchFunction(dispatch, getState);
+ }
+ };
+ },
+
+ ...createServerSideCollectionHandlers('paged', '/queue', (state) => state.queue, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_QUEUE,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_QUEUE_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_QUEUE_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_QUEUE_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_QUEUE_SORT
+ }),
+
+ [types.GRAB_QUEUE_ITEM]: function(payload) {
+ const section = 'paged';
+
+ const {
+ id
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(updateItem({ section, id, isGrabbing: true }));
+
+ const promise = $.ajax({
+ url: `/queue/grab/${id}`,
+ method: 'POST'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ dispatch(fetchQueue()),
+
+ set({
+ section,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id,
+ isGrabbing: false,
+ grabError: xhr
+ }));
+ });
+ };
+ },
+
+ [types.GRAB_QUEUE_ITEMS]: function(payload) {
+ const section = 'paged';
+
+ const {
+ ids
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isGrabbing: true
+ });
+ }),
+
+ set({
+ section,
+ isGrabbing: true
+ })
+ ]));
+
+ const promise = $.ajax({
+ url: '/queue/grab/bulk',
+ method: 'POST',
+ dataType: 'json',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ dispatch(fetchQueue()),
+
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({
+ section,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({ section, isGrabbing: false })
+ ]));
+ });
+ };
+ },
+
+ [types.REMOVE_QUEUE_ITEM]: function(payload) {
+ const section = 'paged';
+
+ const {
+ id,
+ blacklist
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(updateItem({ section, id, isRemoving: true }));
+
+ const promise = $.ajax({
+ url: `/queue/${id}?blacklist=${blacklist}`,
+ method: 'DELETE'
+ });
+
+ promise.done((data) => {
+ dispatch(fetchQueue());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({ section, id, isRemoving: false }));
+ });
+ };
+ },
+
+ [types.REMOVE_QUEUE_ITEMS]: function(payload) {
+ const section = 'paged';
+
+ const {
+ ids,
+ blacklist
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isRemoving: true
+ });
+ }),
+
+ set({ section, isRemoving: true })
+ ]));
+
+ const promise = $.ajax({
+ url: `/queue/bulk?blacklist=${blacklist}`,
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ ids })
+ });
+
+ promise.done((data) => {
+ dispatch(fetchQueue());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isRemoving: false
+ });
+ }),
+
+ set({ section, isRemoving: false })
+ ]));
+ });
+ };
+ }
+
+};
+
+export default queueActionHandlers;
diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js
new file mode 100644
index 000000000..9ef9b6a32
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActions.js
@@ -0,0 +1,24 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import queueActionHandlers from './queueActionHandlers';
+
+export const fetchQueueStatus = queueActionHandlers[types.FETCH_QUEUE_STATUS];
+
+export const fetchQueueDetails = queueActionHandlers[types.FETCH_QUEUE_DETAILS];
+export const clearQueueDetails = createAction(types.CLEAR_QUEUE_DETAILS);
+
+export const fetchQueue = queueActionHandlers[types.FETCH_QUEUE];
+export const gotoQueueFirstPage = queueActionHandlers[types.GOTO_FIRST_QUEUE_PAGE];
+export const gotoQueuePreviousPage = queueActionHandlers[types.GOTO_PREVIOUS_QUEUE_PAGE];
+export const gotoQueueNextPage = queueActionHandlers[types.GOTO_NEXT_QUEUE_PAGE];
+export const gotoQueueLastPage = queueActionHandlers[types.GOTO_LAST_QUEUE_PAGE];
+export const gotoQueuePage = queueActionHandlers[types.GOTO_QUEUE_PAGE];
+export const setQueueSort = queueActionHandlers[types.SET_QUEUE_SORT];
+export const setQueueTableOption = createAction(types.SET_QUEUE_TABLE_OPTION);
+export const clearQueue = createAction(types.CLEAR_QUEUE);
+
+export const setQueueEpisodes = createAction(types.SET_QUEUE_EPISODES);
+export const grabQueueItem = queueActionHandlers[types.GRAB_QUEUE_ITEM];
+export const grabQueueItems = queueActionHandlers[types.GRAB_QUEUE_ITEMS];
+export const removeQueueItem = queueActionHandlers[types.REMOVE_QUEUE_ITEM];
+export const removeQueueItems = queueActionHandlers[types.REMOVE_QUEUE_ITEMS];
diff --git a/frontend/src/Store/Actions/releaseActionHandlers.js b/frontend/src/Store/Actions/releaseActionHandlers.js
new file mode 100644
index 000000000..15ebaa2b0
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActionHandlers.js
@@ -0,0 +1,47 @@
+import $ from 'jquery';
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+import { updateRelease } from './releaseActions';
+
+const section = 'releases';
+
+const releaseActionHandlers = {
+ [types.FETCH_RELEASES]: createFetchHandler(section, '/release'),
+
+ [types.GRAB_RELEASE]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ }
+};
+
+export default releaseActionHandlers;
diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
new file mode 100644
index 000000000..580ffe804
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -0,0 +1,9 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import releaseActionHandlers from './releaseActionHandlers';
+
+export const fetchReleases = releaseActionHandlers[types.FETCH_RELEASES];
+export const setReleasesSort = createAction(types.SET_RELEASES_SORT);
+export const clearReleases = createAction(types.CLEAR_RELEASES);
+export const grabRelease = releaseActionHandlers[types.GRAB_RELEASE];
+export const updateRelease = createAction(types.UPDATE_RELEASE);
diff --git a/frontend/src/Store/Actions/rootFolderActionHandlers.js b/frontend/src/Store/Actions/rootFolderActionHandlers.js
new file mode 100644
index 000000000..f437721b7
--- /dev/null
+++ b/frontend/src/Store/Actions/rootFolderActionHandlers.js
@@ -0,0 +1,59 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { set, updateItem } from './baseActions';
+
+const section = 'rootFolders';
+
+const rootFolderActionHandlers = {
+ [types.FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'),
+
+ [types.DELETE_ROOT_FOLDER]: createRemoveItemHandler('rootFolders',
+ '/rootFolder',
+ (state) => state.rootFolders),
+
+ [types.ADD_ROOT_FOLDER]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ }
+};
+
+export default rootFolderActionHandlers;
diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js
new file mode 100644
index 000000000..0d0b8112a
--- /dev/null
+++ b/frontend/src/Store/Actions/rootFolderActions.js
@@ -0,0 +1,6 @@
+import * as types from './actionTypes';
+import rootFolderActionHandlers from './rootFolderActionHandlers';
+
+export const fetchRootFolders = rootFolderActionHandlers[types.FETCH_ROOT_FOLDERS];
+export const addRootFolder = rootFolderActionHandlers[types.ADD_ROOT_FOLDER];
+export const deleteRootFolder = rootFolderActionHandlers[types.DELETE_ROOT_FOLDER];
diff --git a/frontend/src/Store/Actions/seasonPassActionHandlers.js b/frontend/src/Store/Actions/seasonPassActionHandlers.js
new file mode 100644
index 000000000..8c9fd9b55
--- /dev/null
+++ b/frontend/src/Store/Actions/seasonPassActionHandlers.js
@@ -0,0 +1,83 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import getMonitoringOptions from 'Utilities/Series/getMonitoringOptions';
+import * as types from './actionTypes';
+import { set } from './baseActions';
+import { fetchArtist } from './seriesActions';
+
+const section = 'seasonPass';
+
+const seasonPassActionHandlers = {
+ [types.SAVE_SEASON_PASS]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ artistIds,
+ monitored,
+ monitor
+ } = payload;
+
+ let monitoringOptions = null;
+ const series = [];
+ const allSeries = getState().series.items;
+
+ artistIds.forEach((id) => {
+ const s = _.find(allSeries, { id });
+ const seriesToUpdate = { id };
+
+ if (payload.hasOwnProperty('monitored')) {
+ seriesToUpdate.monitored = monitored;
+ }
+
+ if (monitor) {
+ const {
+ seasons,
+ options: seriesMonitoringOptions
+ } = getMonitoringOptions(_.cloneDeep(s.seasons), monitor);
+
+ if (!monitoringOptions) {
+ monitoringOptions = seriesMonitoringOptions;
+ }
+
+ seriesToUpdate.seasons = seasons;
+ }
+
+ series.push(seriesToUpdate);
+ });
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/seasonPass',
+ method: 'POST',
+ data: JSON.stringify({
+ series,
+ monitoringOptions
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(fetchArtist());
+
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default seasonPassActionHandlers;
diff --git a/frontend/src/Store/Actions/seasonPassActions.js b/frontend/src/Store/Actions/seasonPassActions.js
new file mode 100644
index 000000000..34851458c
--- /dev/null
+++ b/frontend/src/Store/Actions/seasonPassActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import seasonPassActionHandlers from './seasonPassActionHandlers';
+
+export const setSeasonPassSort = createAction(types.SET_SEASON_PASS_SORT);
+export const setSeasonPassFilter = createAction(types.SET_SEASON_PASS_FILTER);
+export const saveSeasonPass = seasonPassActionHandlers[types.SAVE_SEASON_PASS];
diff --git a/frontend/src/Store/Actions/seriesActions.js b/frontend/src/Store/Actions/seriesActions.js
new file mode 100644
index 000000000..21f97a129
--- /dev/null
+++ b/frontend/src/Store/Actions/seriesActions.js
@@ -0,0 +1,16 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import artistActionHandlers from './artistActionHandlers';
+
+export const fetchArtist = artistActionHandlers[types.FETCH_ARTIST];
+export const saveArtist = artistActionHandlers[types.SAVE_ARTIST];
+export const deleteArtist = artistActionHandlers[types.DELETE_ARTIST];
+export const toggleSeriesMonitored = artistActionHandlers[types.TOGGLE_ARTIST_MONITORED];
+export const toggleSeasonMonitored = artistActionHandlers[types.TOGGLE_ALBUM_MONITORED];
+
+export const setSeriesValue = createAction(types.SET_ARTIST_VALUE, (payload) => {
+ return {
+ section: 'series',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/seriesEditorActionHandlers.js b/frontend/src/Store/Actions/seriesEditorActionHandlers.js
new file mode 100644
index 000000000..07b577723
--- /dev/null
+++ b/frontend/src/Store/Actions/seriesEditorActionHandlers.js
@@ -0,0 +1,86 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import { set, updateItem } from './baseActions';
+
+const section = 'seriesEditor';
+
+const seriesEditorActionHandlers = {
+ [types.SAVE_ARTIST_EDITOR]: function(payload) {
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ },
+
+ [types.BULK_DELETE_ARTIST]: function(payload) {
+ return function(dispatch, getState) {
+ 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 serires from the collection
+
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default seriesEditorActionHandlers;
diff --git a/frontend/src/Store/Actions/seriesEditorActions.js b/frontend/src/Store/Actions/seriesEditorActions.js
new file mode 100644
index 000000000..efd632d72
--- /dev/null
+++ b/frontend/src/Store/Actions/seriesEditorActions.js
@@ -0,0 +1,8 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import seriesEditorActionHandlers from './seriesEditorActionHandlers';
+
+export const setSeriesEditorSort = createAction(types.SET_SERIES_EDITOR_SORT);
+export const setSeriesEditorFilter = createAction(types.SET_SERIES_EDITOR_FILTER);
+export const saveArtistEditor = seriesEditorActionHandlers[types.SAVE_ARTIST_EDITOR];
+export const bulkDeleteArtist = seriesEditorActionHandlers[types.BULK_DELETE_ARTIST];
diff --git a/frontend/src/Store/Actions/settingsActionHandlers.js b/frontend/src/Store/Actions/settingsActionHandlers.js
new file mode 100644
index 000000000..4d7d6d4c1
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActionHandlers.js
@@ -0,0 +1,238 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createFetchSchemaHandler from './Creators/createFetchSchemaHandler';
+import createSaveHandler from './Creators/createSaveHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createTestProviderHandler from './Creators/createTestProviderHandler';
+import { set, update, clearPendingChanges } from './baseActions';
+
+const settingsActionHandlers = {
+ [types.FETCH_UI_SETTINGS]: createFetchHandler('ui', '/config/ui'),
+ [types.SAVE_UI_SETTINGS]: createSaveHandler('ui', '/config/ui', (state) => state.settings.ui),
+
+ [types.FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler('mediaManagement', '/config/mediamanagement'),
+ [types.SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler('mediaManagement', '/config/mediamanagement', (state) => state.settings.mediaManagement),
+
+ [types.FETCH_NAMING_SETTINGS]: createFetchHandler('naming', '/config/naming'),
+ [types.SAVE_NAMING_SETTINGS]: createSaveHandler('naming', '/config/naming', (state) => state.settings.naming),
+
+ [types.FETCH_NAMING_EXAMPLES]: function(payload) {
+ const section = 'namingExamples';
+
+ return function(dispatch, getState) {
+ 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
+ }));
+ });
+ };
+ },
+
+ [types.REORDER_DELAY_PROFILE]: function(payload) {
+ const section = 'delayProfiles';
+
+ return function(dispatch, getState) {
+ 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 }));
+ });
+ };
+ },
+
+ [types.FETCH_QUALITY_PROFILES]: createFetchHandler('qualityProfiles', '/qualityprofile'),
+ [types.FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler('qualityProfiles', '/qualityprofile/schema'),
+
+ [types.SAVE_QUALITY_PROFILE]: createSaveProviderHandler('qualityProfiles',
+ '/qualityprofile',
+ (state) => state.settings.qualityProfiles),
+
+ [types.DELETE_QUALITY_PROFILE]: createRemoveItemHandler('qualityProfiles',
+ '/qualityprofile',
+ (state) => state.settings.qualityProfiles),
+
+ [types.FETCH_LANGUAGE_PROFILES]: createFetchHandler('languageProfiles', '/languageprofile'),
+ [types.FETCH_LANGUAGE_PROFILE_SCHEMA]: createFetchSchemaHandler('languageProfiles', '/languageprofile/schema'),
+
+ [types.SAVE_LANGUAGE_PROFILE]: createSaveProviderHandler('languageProfiles',
+ '/languageprofile',
+ (state) => state.settings.languageProfiles),
+
+ [types.DELETE_LANGUAGE_PROFILE]: createRemoveItemHandler('languageProfiles',
+ '/languageprofile',
+ (state) => state.settings.languageProfiles),
+
+ [types.FETCH_DELAY_PROFILES]: createFetchHandler('delayProfiles', '/delayprofile'),
+
+ [types.SAVE_DELAY_PROFILE]: createSaveProviderHandler('delayProfiles',
+ '/delayprofile',
+ (state) => state.settings.delayProfiles),
+
+ [types.DELETE_DELAY_PROFILE]: createRemoveItemHandler('delayProfiles',
+ '/delayprofile',
+ (state) => state.settings.delayProfiles),
+
+ [types.FETCH_QUALITY_DEFINITIONS]: createFetchHandler('qualityDefinitions', '/qualitydefinition'),
+ [types.SAVE_QUALITY_DEFINITIONS]: createSaveHandler('qualityDefinitions', '/qualitydefinition', (state) => state.settings.qualitydefinitions),
+
+ [types.SAVE_QUALITY_DEFINITIONS]: function() {
+ const section = 'qualityDefinitions';
+
+ return function(dispatch, getState) {
+ 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;
+ }
+
+ const promise = $.ajax({
+ method: 'PUT',
+ url: '/qualityDefinition/update',
+ data: JSON.stringify(upatedDefinitions)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+ clearPendingChanges({ section: 'qualityDefinitions' })
+ ]));
+ });
+ };
+ },
+
+ [types.FETCH_INDEXERS]: createFetchHandler('indexers', '/indexer'),
+ [types.FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler('indexers', '/indexer/schema'),
+
+ [types.SAVE_INDEXER]: createSaveProviderHandler('indexers',
+ '/indexer',
+ (state) => state.settings.indexers),
+
+ [types.DELETE_INDEXER]: createRemoveItemHandler('indexers',
+ '/indexer',
+ (state) => state.settings.indexers),
+
+ [types.TEST_INDEXER]: createTestProviderHandler('indexers',
+ '/indexer',
+ (state) => state.settings.indexers),
+
+ [types.FETCH_INDEXER_OPTIONS]: createFetchHandler('indexerOptions', '/config/indexer'),
+ [types.SAVE_INDEXER_OPTIONS]: createSaveHandler('indexerOptions', '/config/indexer', (state) => state.settings.indexerOptions),
+
+ [types.FETCH_RESTRICTIONS]: createFetchHandler('restrictions', '/restriction'),
+
+ [types.SAVE_RESTRICTION]: createSaveProviderHandler('restrictions',
+ '/restriction',
+ (state) => state.settings.restrictions),
+
+ [types.DELETE_RESTRICTION]: createRemoveItemHandler('restrictions',
+ '/restriction',
+ (state) => state.settings.restrictions),
+
+ [types.FETCH_DOWNLOAD_CLIENTS]: createFetchHandler('downloadClients', '/downloadclient'),
+ [types.FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler('downloadClients', '/downloadclient/schema'),
+
+ [types.SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler('downloadClients',
+ '/downloadclient',
+ (state) => state.settings.downloadClients),
+
+ [types.DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler('downloadClients',
+ '/downloadclient',
+ (state) => state.settings.downloadClients),
+
+ [types.TEST_DOWNLOAD_CLIENT]: createTestProviderHandler('downloadClients',
+ '/downloadclient',
+ (state) => state.settings.downloadClients),
+
+ [types.FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler('downloadClientOptions', '/config/downloadclient'),
+ [types.SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler('downloadClientOptions', '/config/downloadclient', (state) => state.settings.downloadClientOptions),
+
+ [types.FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler('remotePathMappings', '/remotepathmapping'),
+
+ [types.SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler('remotePathMappings',
+ '/remotepathmapping',
+ (state) => state.settings.remotePathMappings),
+
+ [types.DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler('remotePathMappings',
+ '/remotepathmapping',
+ (state) => state.settings.remotePathMappings),
+
+ [types.FETCH_NOTIFICATIONS]: createFetchHandler('notifications', '/notification'),
+ [types.FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler('notifications', '/notification/schema'),
+
+ [types.SAVE_NOTIFICATION]: createSaveProviderHandler('notifications',
+ '/notification',
+ (state) => state.settings.notifications),
+
+ [types.DELETE_NOTIFICATION]: createRemoveItemHandler('notifications',
+ '/notification',
+ (state) => state.settings.notifications),
+
+ [types.TEST_NOTIFICATION]: createTestProviderHandler('notifications',
+ '/notification',
+ (state) => state.settings.notifications),
+
+ [types.FETCH_METADATA]: createFetchHandler('metadata', '/metadata'),
+
+ [types.SAVE_METADATA]: createSaveProviderHandler('metadata',
+ '/metadata',
+ (state) => state.settings.metadata),
+
+ [types.FETCH_GENERAL_SETTINGS]: createFetchHandler('general', '/config/host'),
+ [types.SAVE_GENERAL_SETTINGS]: createSaveHandler('general', '/config/host', (state) => state.settings.general)
+};
+
+export default settingsActionHandlers;
diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js
new file mode 100644
index 000000000..394d25013
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActions.js
@@ -0,0 +1,207 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import settingsActionHandlers from './settingsActionHandlers';
+
+export const toggleAdvancedSettings = createAction(types.TOGGLE_ADVANCED_SETTINGS);
+
+export const fetchUISettings = settingsActionHandlers[types.FETCH_UI_SETTINGS];
+export const saveUISettings = settingsActionHandlers[types.SAVE_UI_SETTINGS];
+export const setUISettingsValue = createAction(types.SET_UI_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'ui',
+ ...payload
+ };
+});
+
+export const fetchMediaManagementSettings = settingsActionHandlers[types.FETCH_MEDIA_MANAGEMENT_SETTINGS];
+export const saveMediaManagementSettings = settingsActionHandlers[types.SAVE_MEDIA_MANAGEMENT_SETTINGS];
+export const setMediaManagementSettingsValue = createAction(types.SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'mediaManagement',
+ ...payload
+ };
+});
+
+export const fetchNamingSettings = settingsActionHandlers[types.FETCH_NAMING_SETTINGS];
+export const saveNamingSettings = settingsActionHandlers[types.SAVE_NAMING_SETTINGS];
+export const setNamingSettingsValue = createAction(types.SET_NAMING_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'naming',
+ ...payload
+ };
+});
+
+export const fetchNamingExamples = settingsActionHandlers[types.FETCH_NAMING_EXAMPLES];
+
+export const fetchQualityProfiles = settingsActionHandlers[types.FETCH_QUALITY_PROFILES];
+export const fetchQualityProfileSchema = settingsActionHandlers[types.FETCH_QUALITY_PROFILE_SCHEMA];
+export const saveQualityProfile = settingsActionHandlers[types.SAVE_QUALITY_PROFILE];
+export const deleteQualityProfile = settingsActionHandlers[types.DELETE_QUALITY_PROFILE];
+
+export const setQualityProfileValue = createAction(types.SET_QUALITY_PROFILE_VALUE, (payload) => {
+ return {
+ section: 'qualityProfiles',
+ ...payload
+ };
+});
+
+export const fetchLanguageProfiles = settingsActionHandlers[types.FETCH_LANGUAGE_PROFILES];
+export const fetchLanguageProfileSchema = settingsActionHandlers[types.FETCH_LANGUAGE_PROFILE_SCHEMA];
+export const saveLanguageProfile = settingsActionHandlers[types.SAVE_LANGUAGE_PROFILE];
+export const deleteLanguageProfile = settingsActionHandlers[types.DELETE_LANGUAGE_PROFILE];
+
+export const setLanguageProfileValue = createAction(types.SET_LANGUAGE_PROFILE_VALUE, (payload) => {
+ return {
+ section: 'languageProfiles',
+ ...payload
+ };
+});
+
+export const fetchDelayProfiles = settingsActionHandlers[types.FETCH_DELAY_PROFILES];
+export const saveDelayProfile = settingsActionHandlers[types.SAVE_DELAY_PROFILE];
+export const deleteDelayProfile = settingsActionHandlers[types.DELETE_DELAY_PROFILE];
+export const reorderDelayProfile = settingsActionHandlers[types.REORDER_DELAY_PROFILE];
+
+export const setDelayProfileValue = createAction(types.SET_DELAY_PROFILE_VALUE, (payload) => {
+ return {
+ section: 'delayProfiles',
+ ...payload
+ };
+});
+
+export const fetchQualityDefinitions = settingsActionHandlers[types.FETCH_QUALITY_DEFINITIONS];
+export const saveQualityDefinitions = settingsActionHandlers[types.SAVE_QUALITY_DEFINITIONS];
+
+export const setQualityDefinitionValue = createAction(types.SET_QUALITY_DEFINITION_VALUE);
+
+export const fetchIndexers = settingsActionHandlers[types.FETCH_INDEXERS];
+export const fetchIndexerSchema = settingsActionHandlers[types.FETCH_INDEXER_SCHEMA];
+export const selectIndexerSchema = createAction(types.SELECT_INDEXER_SCHEMA);
+
+export const saveIndexer = settingsActionHandlers[types.SAVE_INDEXER];
+export const deleteIndexer = settingsActionHandlers[types.DELETE_INDEXER];
+export const testIndexer = settingsActionHandlers[types.TEST_INDEXER];
+
+export const setIndexerValue = createAction(types.SET_INDEXER_VALUE, (payload) => {
+ return {
+ section: 'indexers',
+ ...payload
+ };
+});
+
+export const setIndexerFieldValue = createAction(types.SET_INDEXER_FIELD_VALUE, (payload) => {
+ return {
+ section: 'indexers',
+ ...payload
+ };
+});
+
+export const fetchIndexerOptions = settingsActionHandlers[types.FETCH_INDEXER_OPTIONS];
+export const saveIndexerOptions = settingsActionHandlers[types.SAVE_INDEXER_OPTIONS];
+export const setIndexerOptionsValue = createAction(types.SET_INDEXER_OPTIONS_VALUE, (payload) => {
+ return {
+ section: 'indexerOptions',
+ ...payload
+ };
+});
+
+export const fetchRestrictions = settingsActionHandlers[types.FETCH_RESTRICTIONS];
+export const saveRestriction = settingsActionHandlers[types.SAVE_RESTRICTION];
+export const deleteRestriction = settingsActionHandlers[types.DELETE_RESTRICTION];
+
+export const setRestrictionValue = createAction(types.SET_RESTRICTION_VALUE, (payload) => {
+ return {
+ section: 'restrictions',
+ ...payload
+ };
+});
+
+export const fetchDownloadClients = settingsActionHandlers[types.FETCH_DOWNLOAD_CLIENTS];
+export const fetchDownloadClientSchema = settingsActionHandlers[types.FETCH_DOWNLOAD_CLIENT_SCHEMA];
+export const selectDownloadClientSchema = createAction(types.SELECT_DOWNLOAD_CLIENT_SCHEMA);
+
+export const saveDownloadClient = settingsActionHandlers[types.SAVE_DOWNLOAD_CLIENT];
+export const deleteDownloadClient = settingsActionHandlers[types.DELETE_DOWNLOAD_CLIENT];
+export const testDownloadClient = settingsActionHandlers[types.TEST_DOWNLOAD_CLIENT];
+
+export const setDownloadClientValue = createAction(types.SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
+ return {
+ section: 'downloadClients',
+ ...payload
+ };
+});
+
+export const setDownloadClientFieldValue = createAction(types.SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => {
+ return {
+ section: 'downloadClients',
+ ...payload
+ };
+});
+
+export const fetchDownloadClientOptions = settingsActionHandlers[types.FETCH_DOWNLOAD_CLIENT_OPTIONS];
+export const saveDownloadClientOptions = settingsActionHandlers[types.SAVE_DOWNLOAD_CLIENT_OPTIONS];
+export const setDownloadClientOptionsValue = createAction(types.SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => {
+ return {
+ section: 'downloadClientOptions',
+ ...payload
+ };
+});
+
+export const fetchRemotePathMappings = settingsActionHandlers[types.FETCH_REMOTE_PATH_MAPPINGS];
+export const saveRemotePathMapping = settingsActionHandlers[types.SAVE_REMOTE_PATH_MAPPING];
+export const deleteRemotePathMapping = settingsActionHandlers[types.DELETE_REMOTE_PATH_MAPPING];
+
+export const setRemotePathMappingValue = createAction(types.SET_REMOTE_PATH_MAPPING_VALUE, (payload) => {
+ return {
+ section: 'remotePathMappings',
+ ...payload
+ };
+});
+
+export const fetchNotifications = settingsActionHandlers[types.FETCH_NOTIFICATIONS];
+export const fetchNotificationSchema = settingsActionHandlers[types.FETCH_NOTIFICATION_SCHEMA];
+export const selectNotificationSchema = createAction(types.SELECT_NOTIFICATION_SCHEMA);
+
+export const saveNotification = settingsActionHandlers[types.SAVE_NOTIFICATION];
+export const deleteNotification = settingsActionHandlers[types.DELETE_NOTIFICATION];
+export const testNotification = settingsActionHandlers[types.TEST_NOTIFICATION];
+
+export const setNotificationValue = createAction(types.SET_NOTIFICATION_VALUE, (payload) => {
+ return {
+ section: 'notifications',
+ ...payload
+ };
+});
+
+export const setNotificationFieldValue = createAction(types.SET_NOTIFICATION_FIELD_VALUE, (payload) => {
+ return {
+ section: 'notifications',
+ ...payload
+ };
+});
+
+export const fetchMetadata = settingsActionHandlers[types.FETCH_METADATA];
+export const saveMetadata = settingsActionHandlers[types.SAVE_METADATA];
+
+export const setMetadataValue = createAction(types.SET_METADATA_VALUE, (payload) => {
+ return {
+ section: 'metadata',
+ ...payload
+ };
+});
+
+export const setMetadataFieldValue = createAction(types.SET_METADATA_FIELD_VALUE, (payload) => {
+ return {
+ section: 'metadata',
+ ...payload
+ };
+});
+
+export const fetchGeneralSettings = settingsActionHandlers[types.FETCH_GENERAL_SETTINGS];
+export const saveGeneralSettings = settingsActionHandlers[types.SAVE_GENERAL_SETTINGS];
+export const setGeneralSettingsValue = createAction(types.SET_GENERAL_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'general',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/systemActionHandlers.js b/frontend/src/Store/Actions/systemActionHandlers.js
new file mode 100644
index 000000000..d40674da3
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActionHandlers.js
@@ -0,0 +1,48 @@
+import $ from 'jquery';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+
+const systemActionHandlers = {
+ [types.FETCH_STATUS]: createFetchHandler('status', '/system/status'),
+ [types.FETCH_HEALTH]: createFetchHandler('health', '/health'),
+ [types.FETCH_DISK_SPACE]: createFetchHandler('diskSpace', '/diskspace'),
+ [types.FETCH_TASK]: createFetchHandler('tasks', '/system/task'),
+ [types.FETCH_TASKS]: createFetchHandler('tasks', '/system/task'),
+ [types.FETCH_BACKUPS]: createFetchHandler('backups', '/system/backup'),
+ [types.FETCH_UPDATES]: createFetchHandler('updates', '/update'),
+ [types.FETCH_LOG_FILES]: createFetchHandler('logFiles', '/log/file'),
+ [types.FETCH_UPDATE_LOG_FILES]: createFetchHandler('updateLogFiles', '/log/file/update'),
+
+ ...createServerSideCollectionHandlers('logs', '/log', (state) => state.system, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_LOGS,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_LOGS_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_LOGS_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_LOGS_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_LOGS_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_LOGS_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_LOGS_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_LOGS_FILTER
+ }),
+
+ [types.RESTART]: function() {
+ return function() {
+ $.ajax({
+ url: '/system/restart',
+ method: 'POST'
+ });
+ };
+ },
+
+ [types.SHUTDOWN]: function() {
+ return function() {
+ $.ajax({
+ url: '/system/shutdown',
+ method: 'POST'
+ });
+ };
+ }
+};
+
+export default systemActionHandlers;
diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js
new file mode 100644
index 000000000..b5614d2d3
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActions.js
@@ -0,0 +1,28 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import systemActionHandlers from './systemActionHandlers';
+
+export const fetchStatus = systemActionHandlers[types.FETCH_STATUS];
+export const fetchHealth = systemActionHandlers[types.FETCH_HEALTH];
+export const fetchDiskSpace = systemActionHandlers[types.FETCH_DISK_SPACE];
+
+export const fetchTask = systemActionHandlers[types.FETCH_TASK];
+export const fetchTasks = systemActionHandlers[types.FETCH_TASKS];
+export const fetchBackups = systemActionHandlers[types.FETCH_BACKUPS];
+export const fetchUpdates = systemActionHandlers[types.FETCH_UPDATES];
+
+export const fetchLogs = systemActionHandlers[types.FETCH_LOGS];
+export const gotoLogsFirstPage = systemActionHandlers[types.GOTO_FIRST_LOGS_PAGE];
+export const gotoLogsPreviousPage = systemActionHandlers[types.GOTO_PREVIOUS_LOGS_PAGE];
+export const gotoLogsNextPage = systemActionHandlers[types.GOTO_NEXT_LOGS_PAGE];
+export const gotoLogsLastPage = systemActionHandlers[types.GOTO_LAST_LOGS_PAGE];
+export const gotoLogsPage = systemActionHandlers[types.GOTO_LOGS_PAGE];
+export const setLogsSort = systemActionHandlers[types.SET_LOGS_SORT];
+export const setLogsFilter = systemActionHandlers[types.SET_LOGS_FILTER];
+export const setLogsTableOption = createAction(types.SET_LOGS_TABLE_OPTION);
+
+export const fetchLogFiles = systemActionHandlers[types.FETCH_LOG_FILES];
+export const fetchUpdateLogFiles = systemActionHandlers[types.FETCH_UPDATE_LOG_FILES];
+
+export const restart = systemActionHandlers[types.RESTART];
+export const shutdown = systemActionHandlers[types.SHUTDOWN];
diff --git a/frontend/src/Store/Actions/tagActionHandlers.js b/frontend/src/Store/Actions/tagActionHandlers.js
new file mode 100644
index 000000000..c4e007f6c
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActionHandlers.js
@@ -0,0 +1,28 @@
+import $ from 'jquery';
+import * as types from './actionTypes';
+import { update } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+
+const tagActionHandlers = {
+ [types.FETCH_TAGS]: createFetchHandler('tags', '/tag'),
+
+ [types.ADD_TAG]: function(payload) {
+ return (dispatch, getState) => {
+ 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: 'tags', data: tags }));
+ payload.onTagCreated(data);
+ });
+ };
+ }
+};
+
+export default tagActionHandlers;
diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js
new file mode 100644
index 000000000..45f0141ce
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActions.js
@@ -0,0 +1,5 @@
+import * as types from './actionTypes';
+import tagActionHandlers from './tagActionHandlers';
+
+export const fetchTags = tagActionHandlers[types.FETCH_TAGS];
+export const addTag = tagActionHandlers[types.ADD_TAG];
diff --git a/frontend/src/Store/Actions/wantedActionHandlers.js b/frontend/src/Store/Actions/wantedActionHandlers.js
new file mode 100644
index 000000000..ad560b088
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActionHandlers.js
@@ -0,0 +1,34 @@
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import createBatchToggleEpisodeMonitoredHandler from './Creators/createBatchToggleEpisodeMonitoredHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import * as types from './actionTypes';
+
+const wantedActionHandlers = {
+ ...createServerSideCollectionHandlers('missing', '/wanted/missing', (state) => state.wanted, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_MISSING,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_MISSING_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_MISSING_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_MISSING_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_MISSING_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_MISSING_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_MISSING_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_MISSING_FILTER
+ }),
+
+ [types.BATCH_TOGGLE_MISSING_EPISODES]: createBatchToggleEpisodeMonitoredHandler('missing', (state) => state.wanted.missing),
+
+ ...createServerSideCollectionHandlers('cutoffUnmet', '/wanted/cutoff', (state) => state.wanted, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_CUTOFF_UNMET,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_CUTOFF_UNMET_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_CUTOFF_UNMET_FILTER
+ }),
+
+ [types.BATCH_TOGGLE_CUTOFF_UNMET_EPISODES]: createBatchToggleEpisodeMonitoredHandler('cutoffUnmet', (state) => state.wanted.cutoffUnmet)
+};
+
+export default wantedActionHandlers;
diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js
new file mode 100644
index 000000000..9baac1cd5
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActions.js
@@ -0,0 +1,35 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import wantedActionHandlers from './wantedActionHandlers';
+
+//
+// Missing
+
+export const fetchMissing = wantedActionHandlers[types.FETCH_MISSING];
+export const gotoMissingFirstPage = wantedActionHandlers[types.GOTO_FIRST_MISSING_PAGE];
+export const gotoMissingPreviousPage = wantedActionHandlers[types.GOTO_PREVIOUS_MISSING_PAGE];
+export const gotoMissingNextPage = wantedActionHandlers[types.GOTO_NEXT_MISSING_PAGE];
+export const gotoMissingLastPage = wantedActionHandlers[types.GOTO_LAST_MISSING_PAGE];
+export const gotoMissingPage = wantedActionHandlers[types.GOTO_MISSING_PAGE];
+export const setMissingSort = wantedActionHandlers[types.SET_MISSING_SORT];
+export const setMissingFilter = wantedActionHandlers[types.SET_MISSING_FILTER];
+export const setMissingTableOption = createAction(types.SET_MISSING_TABLE_OPTION);
+export const clearMissing = createAction(types.CLEAR_MISSING);
+
+export const batchToggleMissingEpisodes = wantedActionHandlers[types.BATCH_TOGGLE_MISSING_EPISODES];
+
+//
+// Cutoff Unmet
+
+export const fetchCutoffUnmet = wantedActionHandlers[types.FETCH_CUTOFF_UNMET];
+export const gotoCutoffUnmetFirstPage = wantedActionHandlers[types.GOTO_FIRST_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetPreviousPage = wantedActionHandlers[types.GOTO_PREVIOUS_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetNextPage = wantedActionHandlers[types.GOTO_NEXT_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetLastPage = wantedActionHandlers[types.GOTO_LAST_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetPage = wantedActionHandlers[types.GOTO_CUTOFF_UNMET_PAGE];
+export const setCutoffUnmetSort = wantedActionHandlers[types.SET_CUTOFF_UNMET_SORT];
+export const setCutoffUnmetFilter = wantedActionHandlers[types.SET_CUTOFF_UNMET_FILTER];
+export const setCutoffUnmetTableOption= createAction(types.SET_CUTOFF_UNMET_TABLE_OPTION);
+export const clearCutoffUnmet= createAction(types.CLEAR_CUTOFF_UNMET);
+
+export const batchToggleCutoffUnmetEpisodes = wantedActionHandlers[types.BATCH_TOGGLE_CUTOFF_UNMET_EPISODES];
diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js
new file mode 100644
index 000000000..0367e06e1
--- /dev/null
+++ b/frontend/src/Store/Middleware/middlewares.js
@@ -0,0 +1,39 @@
+import { applyMiddleware, compose } from 'redux';
+import ravenMiddleware from 'redux-raven-middleware';
+import thunk from 'redux-thunk';
+import { routerMiddleware } from 'react-router-redux';
+import persistState from './persistState';
+
+export default function(history) {
+ const {
+ analytics,
+ branch,
+ version,
+ release,
+ isProduction
+ } = window.Sonarr;
+
+ const dsn = isProduction ? 'https://b80ca60625b443c38b242e0d21681eb7@sentry.sonarr.tv/13' :
+ 'https://8dbaacdfe2ff4caf97dc7945aecf9ace@sentry.sonarr.tv/12';
+
+ const middlewares = [];
+
+ if (analytics) {
+ middlewares.push(ravenMiddleware(dsn, {
+ environment: isProduction ? 'production' : 'development',
+ release,
+ tags: {
+ branch,
+ version
+ }
+ }));
+ }
+
+ middlewares.push(routerMiddleware(history));
+ middlewares.push(thunk);
+
+ return compose(
+ applyMiddleware(...middlewares),
+ persistState
+ );
+}
diff --git a/frontend/src/Store/Middleware/persistState.js b/frontend/src/Store/Middleware/persistState.js
new file mode 100644
index 000000000..30bd47ace
--- /dev/null
+++ b/frontend/src/Store/Middleware/persistState.js
@@ -0,0 +1,119 @@
+import _ from 'lodash';
+import persistState from 'redux-localstorage';
+import * as addSeriesReducers from 'Store/Reducers/addSeriesReducers';
+import * as episodeReducers from 'Store/Reducers/episodeReducers';
+import * as artistIndexReducers from 'Store/Reducers/artistIndexReducers';
+import * as seriesEditorReducers from 'Store/Reducers/seriesEditorReducers';
+import * as seasonPassReducers from 'Store/Reducers/seasonPassReducers';
+import * as calendarReducers from 'Store/Reducers/calendarReducers';
+import * as historyReducers from 'Store/Reducers/historyReducers';
+import * as blacklistReducers from 'Store/Reducers/blacklistReducers';
+import * as wantedReducers from 'Store/Reducers/wantedReducers';
+import * as settingsReducers from 'Store/Reducers/settingsReducers';
+import * as systemReducers from 'Store/Reducers/systemReducers';
+import * as interactiveImportReducers from 'Store/Reducers/interactiveImportReducers';
+import * as queueReducers from 'Store/Reducers/queueReducers';
+
+const reducers = [
+ addSeriesReducers,
+ episodeReducers,
+ artistIndexReducers,
+ seriesEditorReducers,
+ seasonPassReducers,
+ calendarReducers,
+ historyReducers,
+ blacklistReducers,
+ wantedReducers,
+ settingsReducers,
+ systemReducers,
+ interactiveImportReducers,
+ queueReducers
+];
+
+const columnPaths = [];
+
+const paths = _.reduce(reducers, (acc, reducer) => {
+ reducer.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 persistState(paths, config);
diff --git a/frontend/src/Store/Reducers/Creators/createAddItemReducer.js b/frontend/src/Store/Reducers/Creators/createAddItemReducer.js
new file mode 100644
index 000000000..d0e75c758
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createAddItemReducer.js
@@ -0,0 +1,23 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createAddItemReducer(section) {
+ return (state, { payload }) => {
+ const {
+ section: payloadSection,
+ ...otherProps
+ } = payload;
+
+ if (section === payloadSection) {
+ const newState = getSectionState(state, section);
+
+ newState.items = [...newState.items, { ...otherProps }];
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createAddItemReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createClearPendingChangesReducer.js b/frontend/src/Store/Reducers/Creators/createClearPendingChangesReducer.js
new file mode 100644
index 000000000..6ff6e7b25
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createClearPendingChangesReducer.js
@@ -0,0 +1,21 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createClearPendingChangesReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = {};
+
+ if (newState.hasOwnProperty('saveError')) {
+ newState.saveError = null;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createClearPendingChangesReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createClearReducer.js b/frontend/src/Store/Reducers/Creators/createClearReducer.js
new file mode 100644
index 000000000..2952973a9
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/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/Reducers/Creators/createReducers.js b/frontend/src/Store/Reducers/Creators/createReducers.js
new file mode 100644
index 000000000..13ed584e8
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createReducers.js
@@ -0,0 +1,20 @@
+function createReducers(sections, createReducer) {
+ const reducers = {};
+
+ sections.forEach((section) => {
+ reducers[section] = createReducer(section);
+ });
+
+ return (state, action) => {
+ const section = action.payload.section;
+ const reducer = reducers[section];
+
+ if (reducer) {
+ return reducer(state, action);
+ }
+
+ return state;
+ };
+}
+
+export default createReducers;
diff --git a/frontend/src/Store/Reducers/Creators/createRemoveItemReducer.js b/frontend/src/Store/Reducers/Creators/createRemoveItemReducer.js
new file mode 100644
index 000000000..c09655b0c
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createRemoveItemReducer.js
@@ -0,0 +1,20 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createRemoveItemReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = getSectionState(state, section);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createRemoveItemReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionFilterReducer.js
new file mode 100644
index 000000000..d756a736e
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionFilterReducer.js
@@ -0,0 +1,17 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { filterTypes } from 'Helpers/Props';
+
+function createSetClientSideCollectionFilterReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ newState.filterKey = payload.filterKey;
+ newState.filterValue = payload.filterValue;
+ newState.filterType = payload.filterType || filterTypes.EQUAL;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionFilterReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionSortReducer.js
new file mode 100644
index 000000000..07f57e970
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/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/Reducers/Creators/createSetProviderFieldValueReducer.js b/frontend/src/Store/Reducers/Creators/createSetProviderFieldValueReducer.js
new file mode 100644
index 000000000..3af58dd3b
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/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/Reducers/Creators/createSetReducer.js b/frontend/src/Store/Reducers/Creators/createSetReducer.js
new file mode 100644
index 000000000..2c673b927
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetReducer.js
@@ -0,0 +1,45 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const whitelistedProperties = [
+ 'isFetching',
+ 'isPopulated',
+ 'error',
+ 'isFetchingSchema',
+ 'schemaPopulated',
+ 'schemaError',
+ 'schema',
+ 'selectedSchema',
+ 'isSaving',
+ 'saveError',
+ 'isTesting',
+ 'isDeleting',
+ 'deleteError',
+ 'pendingChanges',
+ 'filterKey',
+ 'filterValue',
+ 'page',
+ 'sortKey',
+ 'sortDirection'
+];
+
+const blacklistedProperties = [
+ 'section',
+ 'id'
+];
+
+function createSetReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = Object.assign(getSectionState(state, section),
+ _.omit(payload, blacklistedProperties));
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetSettingValueReducer.js b/frontend/src/Store/Reducers/Creators/createSetSettingValueReducer.js
new file mode 100644
index 000000000..33ac23044
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/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)) {
+ 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/Reducers/Creators/createSetTableOptionReducer.js b/frontend/src/Store/Reducers/Creators/createSetTableOptionReducer.js
new file mode 100644
index 000000000..5f36e7940
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetTableOptionReducer.js
@@ -0,0 +1,19 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const whitelistedProperties = [
+ 'pageSize',
+ 'columns'
+];
+
+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/Reducers/Creators/createUpdateItemReducer.js b/frontend/src/Store/Reducers/Creators/createUpdateItemReducer.js
new file mode 100644
index 000000000..aba730afd
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createUpdateItemReducer.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createUpdateItemReducer(section, idProp = 'id') {
+ return (state, { payload }) => {
+ const {
+ section: payloadSection,
+ updateOnly = false,
+ ...otherProps
+ } = payload;
+
+ if (section === payloadSection) {
+ const newState = getSectionState(state, section);
+ const items = newState.items;
+ const index = _.findIndex(items, { [idProp]: payload[idProp] });
+
+ 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, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createUpdateItemReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createUpdateReducer.js b/frontend/src/Store/Reducers/Creators/createUpdateReducer.js
new file mode 100644
index 000000000..ea566ad9b
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createUpdateReducer.js
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createUpdateReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = getSectionState(state, section);
+
+ if (_.isArray(payload.data)) {
+ newState.items = payload.data;
+ } else {
+ newState.item = payload.data;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createUpdateReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createUpdateServerSideCollectionReducer.js b/frontend/src/Store/Reducers/Creators/createUpdateServerSideCollectionReducer.js
new file mode 100644
index 000000000..235a1016a
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createUpdateServerSideCollectionReducer.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createUpdateServerSideCollectionReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const data = payload.data;
+ const newState = getSectionState(state, section);
+
+ const serverState = _.omit(data, ['records']);
+ const calculatedState = {
+ totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1),
+ items: data.records
+ };
+
+ return updateSectionState(state, section, Object.assign(newState, serverState, calculatedState));
+ }
+
+ return state;
+ };
+}
+
+export default createUpdateServerSideCollectionReducer;
diff --git a/frontend/src/Store/Reducers/addSeriesReducers.js b/frontend/src/Store/Reducers/addSeriesReducers.js
new file mode 100644
index 000000000..1432840dc
--- /dev/null
+++ b/frontend/src/Store/Reducers/addSeriesReducers.js
@@ -0,0 +1,68 @@
+import { handleActions } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createSetSettingValueReducer from './Creators/createSetSettingValueReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isAdding: false,
+ isAdded: false,
+ addError: null,
+ items: [],
+
+ defaults: {
+ rootFolderPath: '',
+ monitor: 'allEpisodes',
+ qualityProfileId: 0,
+ languageProfileId: 0,
+ seriesType: 'standard',
+ albumFolder: true,
+ tags: []
+ }
+};
+
+export const persistState = [
+ 'addSeries.defaults'
+];
+
+const reducerSection = 'addSeries';
+
+const addSeriesReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.SET_ADD_SERIES_VALUE]: createSetSettingValueReducer(reducerSection),
+
+ [types.SET_ADD_SERIES_DEFAULT]: function(state, { payload }) {
+ const newState = getSectionState(state, reducerSection);
+
+ newState.defaults = {
+ ...newState.defaults,
+ ...payload
+ };
+
+ return updateSectionState(state, reducerSection, newState);
+ },
+
+ [types.CLEAR_ADD_SERIES]: function(state) {
+ const {
+ defaults,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState);
+
+export default addSeriesReducers;
diff --git a/frontend/src/Store/Reducers/appReducers.js b/frontend/src/Store/Reducers/appReducers.js
new file mode 100644
index 000000000..f574495e3
--- /dev/null
+++ b/frontend/src/Store/Reducers/appReducers.js
@@ -0,0 +1,74 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+function getDimensions(width, height) {
+ const dimensions = {
+ width,
+ height,
+ isExtraSmallScreen: width <= 480,
+ isSmallScreen: width <= 768,
+ isMediumScreen: width <= 992,
+ isLargeScreen: width <= 1200
+ };
+
+ return dimensions;
+}
+
+export const defaultState = {
+ dimensions: getDimensions(window.innerWidth, window.innerHeight),
+ messages: {
+ items: []
+ },
+ version: window.Sonarr.version,
+ isUpdated: false,
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false,
+ isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
+};
+
+const appReducers = handleActions({
+
+ [types.SAVE_DIMENSIONS]: function(state, { payload }) {
+ const {
+ width,
+ height
+ } = payload;
+
+ const dimensions = getDimensions(width, height);
+
+ return Object.assign({}, state, { dimensions });
+ },
+
+ [types.SHOW_MESSAGE]: createUpdateItemReducer('messages'),
+ [types.HIDE_MESSAGE]: createRemoveItemReducer('messages'),
+
+ [types.SET_APP_VALUE]: createSetReducer('app'),
+ [types.SET_VERSION]: function(state, { payload }) {
+ const version = payload.version;
+
+ const newState = {
+ version
+ };
+
+ if (state.version !== version) {
+ newState.isUpdated = true;
+ }
+
+ return Object.assign({}, state, newState);
+ },
+
+ [types.SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) {
+ const newState = {
+ isSidebarVisible: payload.isSidebarVisible
+ };
+
+ return Object.assign({}, state, newState);
+ }
+
+}, defaultState);
+
+export default appReducers;
diff --git a/frontend/src/Store/Reducers/artistIndexReducers.js b/frontend/src/Store/Reducers/artistIndexReducers.js
new file mode 100644
index 000000000..9a5e14e03
--- /dev/null
+++ b/frontend/src/Store/Reducers/artistIndexReducers.js
@@ -0,0 +1,213 @@
+import moment from 'moment';
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/createSetClientSideCollectionFilterReducer';
+
+export const defaultState = {
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ filterKey: null,
+ filterValue: null,
+ filterType: filterTypes.EQUAL,
+ view: 'posters',
+
+ posterOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: false,
+ showQualityProfile: false
+ },
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'sortName',
+ label: 'Artist Name',
+ 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: 'albumCount',
+ label: 'Albums',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackProgress',
+ label: 'Tracks',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackCount',
+ label: 'Track 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: '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: {
+ 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 {
+ episodeCount = 0,
+ episodeFileCount
+ } = item;
+
+ const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
+
+ return progress + episodeCount / 1000000;
+ }
+ },
+
+ filterPredicates: {
+ missing: function(item) {
+ return item.episodeCount - item.episodeFileCount > 0;
+ }
+ }
+};
+
+export const persistState = [
+ 'seriesIndex.sortKey',
+ 'seriesIndex.sortDirection',
+ 'seriesIndex.filterKey',
+ 'seriesIndex.filterValue',
+ 'seriesIndex.filterType',
+ 'seriesIndex.view',
+ 'seriesIndex.columns',
+ 'seriesIndex.posterOptions'
+];
+
+const reducerSection = 'seriesIndex';
+
+const artistIndexReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.SET_ARTIST_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+ [types.SET_ARTIST_FILTER]: createSetClientSideCollectionFilterReducer(reducerSection),
+
+ [types.SET_ARTIST_VIEW]: function(state, { payload }) {
+ return Object.assign({}, state, { view: payload.view });
+ },
+
+ [types.SET_ARTIST_TABLE_OPTION]: createSetTableOptionReducer(reducerSection),
+
+ [types.SET_ARTIST_POSTER_OPTION]: function(state, { payload }) {
+ const posterOptions = state.posterOptions;
+
+ return {
+ ...state,
+ posterOptions: {
+ ...posterOptions,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState);
+
+export default artistIndexReducers;
diff --git a/frontend/src/Store/Reducers/blacklistReducers.js b/frontend/src/Store/Reducers/blacklistReducers.js
new file mode 100644
index 000000000..e7cab587d
--- /dev/null
+++ b/frontend/src/Store/Reducers/blacklistReducers.js
@@ -0,0 +1,80 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+
+const reducerSection = 'blacklist';
+
+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: 'details',
+ columnLabel: 'Details',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'blacklist.pageSize',
+ 'blacklist.sortKey',
+ 'blacklist.sortDirection',
+ 'blacklist.columns'
+];
+
+const blacklistReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createUpdateServerSideCollectionReducer(reducerSection),
+ [types.SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(reducerSection)
+
+}, defaultState);
+
+export default blacklistReducers;
diff --git a/frontend/src/Store/Reducers/calendarReducers.js b/frontend/src/Store/Reducers/calendarReducers.js
new file mode 100644
index 000000000..4dea88e97
--- /dev/null
+++ b/frontend/src/Store/Reducers/calendarReducers.js
@@ -0,0 +1,48 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ start: null,
+ end: null,
+ dates: [],
+ dayCount: 7,
+ view: window.innerWidth > 768 ? 'week' : 'day',
+ unmonitored: false,
+ showUpcoming: true,
+ error: null,
+ items: []
+};
+
+export const persistState = [
+ 'calendar.view',
+ 'calendar.unmonitored',
+ 'calendar.showUpcoming'
+];
+
+const section = 'calendar';
+
+const calendarReducers = handleActions({
+
+ [types.SET]: createSetReducer(section),
+ [types.UPDATE]: createUpdateReducer(section),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(section),
+
+ [types.CLEAR_CALENDAR]: (state) => {
+ const {
+ view,
+ unmonitored,
+ showUpcoming,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState);
+
+export default calendarReducers;
diff --git a/frontend/src/Store/Reducers/captchaReducers.js b/frontend/src/Store/Reducers/captchaReducers.js
new file mode 100644
index 000000000..67372839f
--- /dev/null
+++ b/frontend/src/Store/Reducers/captchaReducers.js
@@ -0,0 +1,32 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+export const defaultState = {
+ refreshing: false,
+ token: null,
+ siteKey: null,
+ secretToken: null,
+ ray: null,
+ stoken: null,
+ responseUrl: null
+};
+
+const section = 'captcha';
+
+const captchaReducers = handleActions({
+
+ [types.SET_CAPTCHA_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [types.RESET_CAPTCHA]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState);
+
+export default captchaReducers;
diff --git a/frontend/src/Store/Reducers/commandReducers.js b/frontend/src/Store/Reducers/commandReducers.js
new file mode 100644
index 000000000..b2b474e65
--- /dev/null
+++ b/frontend/src/Store/Reducers/commandReducers.js
@@ -0,0 +1,64 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ handlers: {}
+};
+
+const reducerSection = 'commands';
+
+const commandReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+
+ [types.ADD_COMMAND]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ newState.items = [...state.items, payload];
+
+ return newState;
+ },
+
+ [types.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;
+ },
+
+ [types.REGISTER_FINISH_COMMAND_HANDLER]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.handlers[payload.key] = {
+ name: payload.name,
+ handler: payload.handler
+ };
+
+ return newState;
+ },
+
+ [types.UNREGISTER_FINISH_COMMAND_HANDLER]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ delete newState.handlers[payload.key];
+
+ return newState;
+ }
+
+}, defaultState);
+
+export default commandReducers;
diff --git a/frontend/src/Store/Reducers/episodeFileReducers.js b/frontend/src/Store/Reducers/episodeFileReducers.js
new file mode 100644
index 000000000..904175863
--- /dev/null
+++ b/frontend/src/Store/Reducers/episodeFileReducers.js
@@ -0,0 +1,34 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+const reducerSection = 'episodeFiles';
+
+const episodeFileReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.CLEAR_EPISODE_FILES]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState);
+
+export default episodeFileReducers;
diff --git a/frontend/src/Store/Reducers/episodeHistoryReducers.js b/frontend/src/Store/Reducers/episodeHistoryReducers.js
new file mode 100644
index 000000000..6bf246cb1
--- /dev/null
+++ b/frontend/src/Store/Reducers/episodeHistoryReducers.js
@@ -0,0 +1,26 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+const reducerSection = 'episodeHistory';
+
+const episodeHistoryReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+
+ [types.CLEAR_EPISODE_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState);
+
+export default episodeHistoryReducers;
diff --git a/frontend/src/Store/Reducers/episodeReducers.js b/frontend/src/Store/Reducers/episodeReducers.js
new file mode 100644
index 000000000..4c21e4fc3
--- /dev/null
+++ b/frontend/src/Store/Reducers/episodeReducers.js
@@ -0,0 +1,106 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+
+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: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'episodes.columns'
+];
+
+const reducerSection = 'episodes';
+
+const episodeReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+
+ [types.SET_EPISODES_TABLE_OPTION]: createSetTableOptionReducer(reducerSection),
+
+ [types.CLEAR_EPISODES]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ },
+
+ [types.SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(reducerSection)
+
+}, defaultState);
+
+export default episodeReducers;
diff --git a/frontend/src/Store/Reducers/historyReducers.js b/frontend/src/Store/Reducers/historyReducers.js
new file mode 100644
index 000000000..7ebca5212
--- /dev/null
+++ b/frontend/src/Store/Reducers/historyReducers.js
@@ -0,0 +1,113 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createClearReducer from './Creators/createClearReducer';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: null,
+ filterValue: null,
+ 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
+ }
+ ]
+};
+
+export const persistState = [
+ 'history.pageSize',
+ 'history.sortKey',
+ 'history.sortDirection',
+ 'history.filterKey',
+ 'history.filterValue'
+];
+
+const serverSideCollectionName = 'history';
+
+const historyReducers = handleActions({
+
+ [types.SET]: createSetReducer(serverSideCollectionName),
+ [types.UPDATE]: createUpdateReducer(serverSideCollectionName),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(serverSideCollectionName),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createUpdateServerSideCollectionReducer(serverSideCollectionName),
+
+ [types.SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(serverSideCollectionName),
+
+ [types.CLEAR_HISTORY]: createClearReducer('history', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+
+}, defaultState);
+
+export default historyReducers;
diff --git a/frontend/src/Store/Reducers/importSeriesReducers.js b/frontend/src/Store/Reducers/importSeriesReducers.js
new file mode 100644
index 000000000..9aa86697b
--- /dev/null
+++ b/frontend/src/Store/Reducers/importSeriesReducers.js
@@ -0,0 +1,35 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isImporting: false,
+ isImported: false,
+ importError: null,
+ items: []
+};
+
+const reducerSection = 'importSeries';
+
+const importSeriesReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.CLEAR_IMPORT_SERIES]: function(state) {
+ return Object.assign({}, state, defaultState);
+ },
+
+ [types.SET_IMPORT_SERIES_VALUE]: createUpdateItemReducer(reducerSection)
+
+}, defaultState);
+
+export default importSeriesReducers;
diff --git a/frontend/src/Store/Reducers/index.js b/frontend/src/Store/Reducers/index.js
new file mode 100644
index 000000000..4d6f91942
--- /dev/null
+++ b/frontend/src/Store/Reducers/index.js
@@ -0,0 +1,88 @@
+import { combineReducers } from 'redux';
+import { enableBatching } from 'redux-batched-actions';
+import { routerReducer } from 'react-router-redux';
+import app, { defaultState as defaultappState } from './appReducers';
+import addSeries, { defaultState as defaultAddSeriesState } from './addSeriesReducers';
+import importSeries, { defaultState as defaultImportSeriesState } from './importSeriesReducers';
+import series, { defaultState as defaultSeriesState } from './seriesReducers';
+import seriesIndex, { defaultState as defaultSeriesIndexState } from './artistIndexReducers';
+import seriesEditor, { defaultState as defaultSeriesEditorState } from './seriesEditorReducers';
+import seasonPass, { defaultState as defaultSeasonPassState } from './seasonPassReducers';
+import calendar, { defaultState as defaultCalendarState } from './calendarReducers';
+import history, { defaultState as defaultHistoryState } from './historyReducers';
+import queue, { defaultState as defaultQueueState } from './queueReducers';
+import blacklist, { defaultState as defaultBlacklistState } from './blacklistReducers';
+import episodes, { defaultState as defaultEpisodesState } from './episodeReducers';
+import episodeFiles, { defaultState as defaultEpisodeFilesState } from './episodeFileReducers';
+import episodeHistory, { defaultState as defaultEpisodeHistoryState } from './episodeHistoryReducers';
+import releases, { defaultState as defaultReleasesState } from './releaseReducers';
+import wanted, { defaultState as defaultWantedState } from './wantedReducers';
+import settings, { defaultState as defaultSettingsState } from './settingsReducers';
+import system, { defaultState as defaultSystemState } from './systemReducers';
+import commands, { defaultState as defaultCommandsState } from './commandReducers';
+import paths, { defaultState as defaultPathsState } from './pathReducers';
+import tags, { defaultState as defaultTagsState } from './tagReducers';
+import captcha, { defaultState as defaultCaptchaState } from './captchaReducers';
+import oAuth, { defaultState as defaultOAuthState } from './oAuthReducers';
+import interactiveImport, { defaultState as defaultInteractiveImportState } from './interactiveImportReducers';
+import rootFolders, { defaultState as defaultRootFoldersState } from './rootFolderReducers';
+import organizePreview, { defaultState as defaultOrganizePreviewState } from './organizePreviewReducers';
+
+export const defaultState = {
+ app: defaultappState,
+ addSeries: defaultAddSeriesState,
+ importSeries: defaultImportSeriesState,
+ series: defaultSeriesState,
+ seriesIndex: defaultSeriesIndexState,
+ seriesEditor: defaultSeriesEditorState,
+ seasonPass: defaultSeasonPassState,
+ calendar: defaultCalendarState,
+ history: defaultHistoryState,
+ queue: defaultQueueState,
+ blacklist: defaultBlacklistState,
+ episodes: defaultEpisodesState,
+ episodeFiles: defaultEpisodeFilesState,
+ episodeHistory: defaultEpisodeHistoryState,
+ releases: defaultReleasesState,
+ wanted: defaultWantedState,
+ settings: defaultSettingsState,
+ system: defaultSystemState,
+ commands: defaultCommandsState,
+ paths: defaultPathsState,
+ tags: defaultTagsState,
+ captcha: defaultCaptchaState,
+ oAuth: defaultOAuthState,
+ interactiveImport: defaultInteractiveImportState,
+ rootFolders: defaultRootFoldersState,
+ organizePreview: defaultOrganizePreviewState
+};
+
+export default enableBatching(combineReducers({
+ app,
+ addSeries,
+ importSeries,
+ series,
+ seriesIndex,
+ seriesEditor,
+ seasonPass,
+ calendar,
+ history,
+ queue,
+ blacklist,
+ episodes,
+ episodeFiles,
+ episodeHistory,
+ releases,
+ wanted,
+ settings,
+ system,
+ commands,
+ paths,
+ tags,
+ captcha,
+ oAuth,
+ interactiveImport,
+ rootFolders,
+ organizePreview,
+ routing: routerReducer
+}));
diff --git a/frontend/src/Store/Reducers/interactiveImportReducers.js b/frontend/src/Store/Reducers/interactiveImportReducers.js
new file mode 100644
index 000000000..d0eea157d
--- /dev/null
+++ b/frontend/src/Store/Reducers/interactiveImportReducers.js
@@ -0,0 +1,97 @@
+import _ from 'lodash';
+import moment from 'moment';
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'quality',
+ sortDirection: sortDirections.DESCENDING,
+ recentFolders: [],
+ importMode: 'move',
+ sortPredicates: {
+ series: function(item, direction) {
+ const series = item.series;
+
+ return series ? series.sortTitle : '';
+ },
+
+ quality: function(item, direction) {
+ return item.quality.qualityWeight;
+ }
+ }
+};
+
+export const persistState = [
+ 'interactiveImport.recentFolders',
+ 'interactiveImport.importMode'
+];
+
+const reducerSection = 'interactiveImport';
+
+const interactiveImportReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+
+ [types.UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => {
+ const id = payload.id;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+ const index = _.findIndex(items, { id });
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [types.ADD_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolder = { folder, lastUsed: moment().toISOString() };
+ const recentFolders = [...state.recentFolders];
+ const index = _.findIndex(recentFolders, { folder });
+
+ if (index > -1) {
+ recentFolders.splice(index, 1, recentFolder);
+ } else {
+ recentFolders.push(recentFolder);
+ }
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [types.REMOVE_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolders = _.remove([...state.recentFolders], { folder });
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [types.CLEAR_INTERACTIVE_IMPORT]: function(state) {
+ const newState = {
+ ...defaultState,
+ recentFolders: state.recentFolders,
+ importMode: state.importMode
+ };
+
+ return newState;
+ },
+
+ [types.SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+
+ [types.SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) {
+ return Object.assign({}, state, { importMode: payload.importMode });
+ }
+
+}, defaultState);
+
+export default interactiveImportReducers;
diff --git a/frontend/src/Store/Reducers/oAuthReducers.js b/frontend/src/Store/Reducers/oAuthReducers.js
new file mode 100644
index 000000000..291faf285
--- /dev/null
+++ b/frontend/src/Store/Reducers/oAuthReducers.js
@@ -0,0 +1,28 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+export const defaultState = {
+ authorizing: false,
+ accessToken: null,
+ accessTokenSecret: null
+};
+
+const section = 'oAuth';
+
+const oAuthReducers = handleActions({
+
+ [types.SET_OAUTH_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [types.RESET_OAUTH]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState);
+
+export default oAuthReducers;
diff --git a/frontend/src/Store/Reducers/organizePreviewReducers.js b/frontend/src/Store/Reducers/organizePreviewReducers.js
new file mode 100644
index 000000000..c4e182c09
--- /dev/null
+++ b/frontend/src/Store/Reducers/organizePreviewReducers.js
@@ -0,0 +1,26 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+const reducerSection = 'organizePreview';
+
+const organizePreviewReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+
+ [types.CLEAR_ORGANIZE_PREVIEW]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState);
+
+export default organizePreviewReducers;
diff --git a/frontend/src/Store/Reducers/pathReducers.js b/frontend/src/Store/Reducers/pathReducers.js
new file mode 100644
index 000000000..c10ad89c7
--- /dev/null
+++ b/frontend/src/Store/Reducers/pathReducers.js
@@ -0,0 +1,45 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+
+export const defaultState = {
+ currentPath: '',
+ isPopulated: false,
+ isFetching: false,
+ error: null,
+ directories: [],
+ files: [],
+ parent: null
+};
+
+const reducerSection = 'paths';
+
+const pathReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.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;
+ },
+
+ [types.CLEAR_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.path = '';
+ newState.directories = [];
+ newState.files = [];
+ newState.parent = '';
+
+ return newState;
+ }
+
+}, defaultState);
+
+export default pathReducers;
diff --git a/frontend/src/Store/Reducers/queueReducers.js b/frontend/src/Store/Reducers/queueReducers.js
new file mode 100644
index 000000000..b313193c2
--- /dev/null
+++ b/frontend/src/Store/Reducers/queueReducers.js
@@ -0,0 +1,165 @@
+import { handleActions } from 'redux-actions';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { sortDirections } from 'Helpers/Props';
+import * as types from 'Store/Actions/actionTypes';
+import createClearReducer from './Creators/createClearReducer';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createReducers from './Creators/createReducers';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+
+export const defaultState = {
+ queueStatus: {
+ 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',
+ isVisible: true
+ },
+ {
+ name: 'episodeTitle',
+ label: 'Episode Title',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'protocol',
+ label: 'Protocol',
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isVisible: false
+ },
+ {
+ name: 'downloadClient',
+ label: 'Download Client',
+ 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
+ }
+ ]
+ },
+
+ queueEpisodes: {
+ isPopulated: false,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'queue.paged.pageSize',
+ 'queue.paged.sortKey',
+ 'queue.paged.sortDirection',
+ 'queue.paged.columns'
+];
+
+const propertyNames = [
+ 'queueStatus',
+ 'details',
+ 'episodes'
+];
+
+const paged = 'paged';
+
+const queueReducers = handleActions({
+
+ [types.SET]: createReducers([...propertyNames, paged], createSetReducer),
+ [types.UPDATE]: createReducers([...propertyNames, paged], createUpdateReducer),
+ [types.UPDATE_ITEM]: createReducers(['queueEpisodes', paged], createUpdateItemReducer),
+
+ [types.CLEAR_QUEUE_DETAILS]: createClearReducer('details', defaultState.details),
+
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createUpdateServerSideCollectionReducer(paged),
+
+ [types.SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
+
+ [types.CLEAR_QUEUE]: createClearReducer('paged', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }),
+
+ [types.SET_QUEUE_EPISODES]: function(state, { payload }) {
+ const section = 'queueEpisodes';
+
+ return updateSectionState(state, section, {
+ isPopulated: true,
+ items: payload.episodes
+ });
+ },
+
+ [types.CLEAR_EPISODES]: (state) => {
+ const section = 'queueEpisodes';
+
+ return updateSectionState(state, section, {
+ isPopulated: false,
+ items: []
+ });
+ }
+
+}, defaultState);
+
+export default queueReducers;
diff --git a/frontend/src/Store/Reducers/releaseReducers.js b/frontend/src/Store/Reducers/releaseReducers.js
new file mode 100644
index 000000000..2f55929d6
--- /dev/null
+++ b/frontend/src/Store/Reducers/releaseReducers.js
@@ -0,0 +1,65 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+
+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;
+ }
+ }
+};
+
+const reducerSection = 'releases';
+
+const releaseReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+
+ [types.CLEAR_RELEASES]: (state) => {
+ return Object.assign({}, state, defaultState);
+ },
+
+ [types.UPDATE_RELEASE]: (state, { payload }) => {
+ const guid = payload.guid;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+ const index = _.findIndex(items, { guid });
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [types.SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(reducerSection)
+
+}, defaultState);
+
+export default releaseReducers;
diff --git a/frontend/src/Store/Reducers/rootFolderReducers.js b/frontend/src/Store/Reducers/rootFolderReducers.js
new file mode 100644
index 000000000..bd5c18bfa
--- /dev/null
+++ b/frontend/src/Store/Reducers/rootFolderReducers.js
@@ -0,0 +1,28 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+const reducerSection = 'rootFolders';
+
+const rootFolderReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection)
+
+}, defaultState);
+
+export default rootFolderReducers;
diff --git a/frontend/src/Store/Reducers/seasonPassReducers.js b/frontend/src/Store/Reducers/seasonPassReducers.js
new file mode 100644
index 000000000..c09a776b4
--- /dev/null
+++ b/frontend/src/Store/Reducers/seasonPassReducers.js
@@ -0,0 +1,39 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/createSetClientSideCollectionFilterReducer';
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ filterKey: null,
+ filterValue: null,
+ filterType: filterTypes.EQUAL
+};
+
+export const persistState = [
+ 'seasonPass.sortKey',
+ 'seasonPass.sortDirection',
+ 'seasonPass.filterKey',
+ 'seasonPass.filterValue',
+ 'seasonPass.filterType'
+];
+
+const reducerSection = 'seasonPass';
+
+const seasonPassReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.SET_SEASON_PASS_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+ [types.SET_SEASON_PASS_FILTER]: createSetClientSideCollectionFilterReducer(reducerSection)
+
+}, defaultState);
+
+export default seasonPassReducers;
diff --git a/frontend/src/Store/Reducers/seriesEditorReducers.js b/frontend/src/Store/Reducers/seriesEditorReducers.js
new file mode 100644
index 000000000..8b6e81411
--- /dev/null
+++ b/frontend/src/Store/Reducers/seriesEditorReducers.js
@@ -0,0 +1,41 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/createSetClientSideCollectionFilterReducer';
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ filterKey: null,
+ filterValue: null,
+ filterType: filterTypes.EQUAL
+};
+
+export const persistState = [
+ 'seriesEditor.sortKey',
+ 'seriesEditor.sortDirection',
+ 'seriesEditor.filterKey',
+ 'seriesEditor.filterValue',
+ 'seriesEditor.filterType'
+];
+
+const reducerSection = 'seriesEditor';
+
+const seriesEditorReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.SET_SERIES_EDITOR_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+ [types.SET_SERIES_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(reducerSection)
+
+}, defaultState);
+
+export default seriesEditorReducers;
diff --git a/frontend/src/Store/Reducers/seriesReducers.js b/frontend/src/Store/Reducers/seriesReducers.js
new file mode 100644
index 000000000..79f274373
--- /dev/null
+++ b/frontend/src/Store/Reducers/seriesReducers.js
@@ -0,0 +1,37 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetSettingValueReducer from './Creators/createSetSettingValueReducer';
+import createClearPendingChangesReducer from './Creators/createClearPendingChangesReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ pendingChanges: {}
+};
+
+const reducerSection = 'series';
+
+const seriesReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.SET_ARTIST_VALUE]: createSetSettingValueReducer(reducerSection),
+ [types.CLEAR_PENDING_CHANGES]: createClearPendingChangesReducer(reducerSection)
+
+}, defaultState);
+
+export default seriesReducers;
diff --git a/frontend/src/Store/Reducers/settingsReducers.js b/frontend/src/Store/Reducers/settingsReducers.js
new file mode 100644
index 000000000..73b4b0055
--- /dev/null
+++ b/frontend/src/Store/Reducers/settingsReducers.js
@@ -0,0 +1,337 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createSetSettingValueReducer from './Creators/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from './Creators/createSetProviderFieldValueReducer';
+import createClearPendingChangesReducer from './Creators/createClearPendingChangesReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+import createReducers from './Creators/createReducers';
+
+export const defaultState = {
+ ui: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ mediaManagement: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ naming: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ namingExamples: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ qualityProfiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ languageProfiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ delayProfiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ qualityDefinitions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ indexers: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ indexerOptions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ restrictions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ downloadClients: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ downloadClientOptions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ remotePathMappings: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ notifications: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ metadata: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ general: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ advancedSettings: false
+};
+
+export const persistState = [
+ 'settings.advancedSettings'
+];
+
+const propertyNames = [
+ 'ui',
+ 'mediaManagement',
+ 'naming',
+ 'namingExamples',
+ 'qualityDefinitions',
+ 'indexerOptions',
+ 'downloadClientOptions',
+ 'general'
+];
+
+const providerPropertyNames = [
+ 'qualityProfiles',
+ 'languageProfiles',
+ 'delayProfiles',
+ 'indexers',
+ 'restrictions',
+ 'downloadClients',
+ 'remotePathMappings',
+ 'notifications',
+ 'metadata'
+];
+
+const settingsReducers = handleActions({
+
+ [types.TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => {
+ return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
+ },
+
+ [types.SET]: createReducers([...propertyNames, ...providerPropertyNames], createSetReducer),
+ [types.UPDATE]: createReducers([...propertyNames, ...providerPropertyNames], createUpdateReducer),
+ [types.UPDATE_ITEM]: createReducers(providerPropertyNames, createUpdateItemReducer),
+ [types.CLEAR_PENDING_CHANGES]: createReducers([...propertyNames, ...providerPropertyNames], createClearPendingChangesReducer),
+
+ [types.REMOVE_ITEM]: createReducers(providerPropertyNames, createRemoveItemReducer),
+
+ [types.SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer('ui'),
+ [types.SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer('mediaManagement'),
+ [types.SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer('naming'),
+ [types.SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer('qualityProfiles'),
+ [types.SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer('languageProfiles'),
+ [types.SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer('delayProfiles'),
+
+ [types.SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) {
+ const section = 'qualityDefinitions';
+ 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);
+ },
+
+ [types.SET_INDEXER_VALUE]: createSetSettingValueReducer('indexers'),
+ [types.SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer('indexers'),
+ [types.SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer('indexerOptions'),
+ [types.SET_RESTRICTION_VALUE]: createSetSettingValueReducer('restrictions'),
+
+ [types.SELECT_INDEXER_SCHEMA]: function(state, { payload }) {
+ return selectProviderSchema(state, 'indexers', payload, (selectedSchema) => {
+ selectedSchema.enableRss = selectedSchema.supportsRss;
+ selectedSchema.enableSearch = selectedSchema.supportsSearch;
+
+ return selectedSchema;
+ });
+ },
+
+ [types.SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer('downloadClients'),
+ [types.SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer('downloadClients'),
+
+ [types.SELECT_DOWNLOAD_CLIENT_SCHEMA]: function(state, { payload }) {
+ return selectProviderSchema(state, 'downloadClients', payload, (selectedSchema) => {
+ selectedSchema.enable = true;
+
+ return selectedSchema;
+ });
+ },
+
+ [types.SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer('downloadClientOptions'),
+ [types.SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer('remotePathMappings'),
+
+ [types.SET_NOTIFICATION_VALUE]: createSetSettingValueReducer('notifications'),
+ [types.SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer('notifications'),
+
+ [types.SELECT_NOTIFICATION_SCHEMA]: function(state, { payload }) {
+ return selectProviderSchema(state, 'notifications', payload, (selectedSchema) => {
+ selectedSchema.onGrab = selectedSchema.supportsOnGrab;
+ selectedSchema.onDownload = selectedSchema.supportsOnDownload;
+ selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
+ selectedSchema.onRename = selectedSchema.supportsOnRename;
+
+ return selectedSchema;
+ });
+ },
+
+ [types.SET_METADATA_VALUE]: createSetSettingValueReducer('metadata'),
+ [types.SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer('metadata'),
+
+ [types.SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer('general')
+
+}, defaultState);
+
+export default settingsReducers;
diff --git a/frontend/src/Store/Reducers/systemReducers.js b/frontend/src/Store/Reducers/systemReducers.js
new file mode 100644
index 000000000..31aa2ab08
--- /dev/null
+++ b/frontend/src/Store/Reducers/systemReducers.js
@@ -0,0 +1,146 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+import createReducers from './Creators/createReducers';
+
+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,
+ items: []
+ },
+
+ updates: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ logs: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 50,
+ sortKey: 'time',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: null,
+ filterValue: null,
+ 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
+ }
+ ]
+ },
+
+ 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.filterKey',
+ 'system.logs.filterValue'
+];
+
+const collectionNames = [
+ 'health',
+ 'diskSpace',
+ 'tasks',
+ 'backups',
+ 'updates',
+ 'logFiles',
+ 'updateLogFiles'
+];
+
+const serverSideCollectionNames = [
+ 'logs'
+];
+
+const systemReducers = handleActions({
+
+ [types.SET]: createReducers(['status', ...collectionNames, ...serverSideCollectionNames], createSetReducer),
+ [types.UPDATE]: createReducers(['status', ...collectionNames, ...serverSideCollectionNames], createUpdateReducer),
+ [types.UPDATE_ITEM]: createUpdateItemReducer('tasks'),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createReducers(serverSideCollectionNames, createUpdateServerSideCollectionReducer),
+
+ [types.SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs')
+
+}, defaultState);
+
+export default systemReducers;
diff --git a/frontend/src/Store/Reducers/tagReducers.js b/frontend/src/Store/Reducers/tagReducers.js
new file mode 100644
index 000000000..6aa822fd7
--- /dev/null
+++ b/frontend/src/Store/Reducers/tagReducers.js
@@ -0,0 +1,22 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+const reducerSection = 'tags';
+
+const tagReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection)
+
+}, defaultState);
+
+export default tagReducers;
diff --git a/frontend/src/Store/Reducers/wantedReducers.js b/frontend/src/Store/Reducers/wantedReducers.js
new file mode 100644
index 000000000..950d0f994
--- /dev/null
+++ b/frontend/src/Store/Reducers/wantedReducers.js
@@ -0,0 +1,161 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createClearReducer from './Creators/createClearReducer';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+import createReducers from './Creators/createReducers';
+
+export const defaultState = {
+ missing: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'airDateUtc',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: 'monitored',
+ filterValue: 'true',
+ 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
+ }
+ ]
+ },
+
+ cutoffUnmet: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'airDateUtc',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: 'monitored',
+ filterValue: true,
+ 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: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'wanted.missing.pageSize',
+ 'wanted.missing.sortKey',
+ 'wanted.missing.sortDirection',
+ 'wanted.missing.filterKey',
+ 'wanted.missing.filterValue',
+ 'wanted.missing.columns',
+ 'wanted.cutoffUnmet.pageSize',
+ 'wanted.cutoffUnmet.sortKey',
+ 'wanted.cutoffUnmet.sortDirection',
+ 'wanted.cutoffUnmet.filterKey',
+ 'wanted.cutoffUnmet.filterValue',
+ 'wanted.cutoffUnmet.columns'
+];
+
+const serverSideCollectionNames = [
+ 'missing',
+ 'cutoffUnmet'
+];
+
+const wantedReducers = handleActions({
+
+ [types.SET]: createReducers(serverSideCollectionNames, createSetReducer),
+ [types.UPDATE]: createReducers(serverSideCollectionNames, createUpdateReducer),
+ [types.UPDATE_ITEM]: createReducers(serverSideCollectionNames, createUpdateItemReducer),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createReducers(serverSideCollectionNames, createUpdateServerSideCollectionReducer),
+
+ [types.SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('missing'),
+ [types.SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('cutoffUnmet'),
+
+ [types.CLEAR_MISSING]: createClearReducer('missing', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }),
+
+ [types.CLEAR_CUTOFF_UNMET]: createClearReducer('cutoffUnmet', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+
+}, defaultState);
+
+export default wantedReducers;
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/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js
new file mode 100644
index 000000000..5ed1a473f
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllSeriesSelector from './createAllSeriesSelector';
+
+function createArtistSelector() {
+ return createSelector(
+ (state, { artistId }) => artistId,
+ createAllSeriesSelector(),
+ (artistId, series) => {
+ return _.find(series, { id: artistId });
+ }
+ );
+}
+
+export default createArtistSelector;
diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
new file mode 100644
index 000000000..e5b740afc
--- /dev/null
+++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
@@ -0,0 +1,117 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+
+const filterTypePredicates = {
+ [filterTypes.CONTAINS]: function(value, filterValue) {
+ return value.toLowerCase().indexOf(filterValue.toLowerCase()) > -1;
+ },
+
+ [filterTypes.EQUAL]: function(value, filterValue) {
+ return value === filterValue;
+ },
+
+ [filterTypes.GREATER_THAN]: function(value, filterValue) {
+ return value > filterValue;
+ },
+
+ [filterTypes.GREATER_THAN_OR_EQUAL]: function(value, filterValue) {
+ return value >= filterValue;
+ },
+
+ [filterTypes.LESS_THAN]: function(value, filterValue) {
+ return value < filterValue;
+ },
+
+ [filterTypes.LESS_THAN_OR_EQUAL]: function(value, filterValue) {
+ return value <= filterValue;
+ },
+
+ [filterTypes.NOT_EQUAL]: function(value, filterValue) {
+ return value !== filterValue;
+ }
+};
+
+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 {
+ filterKey,
+ filterValue,
+ filterType,
+ filterPredicates
+ } = state;
+
+ if (!filterKey || !filterValue) {
+ return items;
+ }
+
+ return _.filter(items, (item) => {
+ if (filterPredicates && filterPredicates.hasOwnProperty(filterKey)) {
+ return filterPredicates[filterKey](item);
+ }
+
+ if (item.hasOwnProperty(filterKey)) {
+ return filterTypePredicates[filterType](item[filterKey], filterValue);
+ }
+
+ return false;
+ });
+}
+
+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 createClientSideCollectionSelector() {
+ return createSelector(
+ (state, { section }) => state[section],
+ (state, { uiSection }) => state[uiSection],
+ (sectionState, uiSectionState = {}) => {
+ const state = Object.assign({}, sectionState, uiSectionState);
+
+ const filtered = filter(state.items, state);
+ const sorted = sort(filtered, state);
+
+ return {
+ ...sectionState,
+ ...uiSectionState,
+ items: sorted
+ };
+ }
+ );
+}
+
+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..ac79194ad
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandExecutingSelector(name) {
+ return createSelector(
+ createCommandsSelector(),
+ (commands) => {
+ return _.some(commands, { name });
+ }
+ );
+}
+
+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..ff5bfe50a
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandSelector(name, contraints = {}) {
+ return createSelector(
+ createCommandsSelector(),
+ (commands) => {
+ return _.some(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..156c48d95
--- /dev/null
+++ b/frontend/src/Store/Selectors/createEpisodeFileSelector.js
@@ -0,0 +1,18 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createEpisodeFileSelector() {
+ return createSelector(
+ (state, { episodeFileId }) => episodeFileId,
+ (state) => state.episodeFiles,
+ (episodeFileId, episodeFiles) => {
+ if (!episodeFileId) {
+ return null;
+ }
+
+ return _.find(episodeFiles.items, { 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..c1018b661
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
@@ -0,0 +1,59 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createProviderSettingsSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state, { section }) => state.settings[section],
+ (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 {
+ isFetchingSchema: isFetching,
+ schemaError: error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ return {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges,
+ ...settings,
+ item: settings.settings
+ };
+ }
+
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError);
+
+ return {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ item: settings.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..3d1eeb766
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQueueItemSelector.js
@@ -0,0 +1,20 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createQueueItemSelector() {
+ return createSelector(
+ (state, { episodeId }) => episodeId,
+ (state) => state.queue.details,
+ (episodeId, details) => {
+ if (!episodeId) {
+ return null;
+ }
+
+ return _.find(details.items, (item) => {
+ return item.episode.id === episodeId;
+ });
+ }
+ );
+}
+
+export default createQueueItemSelector;
diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
new file mode 100644
index 000000000..7ff0d2708
--- /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() {
+ return createSelector(
+ (state, { section }) => 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/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..74e5444c9
--- /dev/null
+++ b/frontend/src/Store/Selectors/selectSettings.js
@@ -0,0 +1,97 @@
+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;
+ }
+
+ 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/connectSection.js b/frontend/src/Store/connectSection.js
new file mode 100644
index 000000000..5d309cc5e
--- /dev/null
+++ b/frontend/src/Store/connectSection.js
@@ -0,0 +1,57 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import getDisplayName from 'Helpers/getDisplayName';
+
+function connectSection(mapStateToProps, mapDispatchToProps, mergeProps, options = {}, sectionOptions = {}) {
+ return function wrap(WrappedComponent) {
+ const ConnectedComponent = connect(mapStateToProps, mapDispatchToProps, mergeProps, options)(WrappedComponent);
+
+ class Section extends Component {
+
+ //
+ // Control
+
+ getWrappedInstance = () => {
+ if (this._wrappedInstance) {
+ return this._wrappedInstance.getWrappedInstance();
+ }
+ }
+
+ //
+ // Listeners
+
+ setWrappedInstanceRef = (ref) => {
+ this._wrappedInstance = ref;
+ }
+
+ //
+ // Render
+
+ render() {
+ if (options.withRef) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+ }
+
+ Section.displayName = `Section(${getDisplayName(WrappedComponent)})`;
+ Section.WrappedComponent = WrappedComponent;
+
+ return Section;
+ };
+}
+
+export default connectSection;
diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js
new file mode 100644
index 000000000..168bbc954
--- /dev/null
+++ b/frontend/src/Store/createAppStore.js
@@ -0,0 +1,15 @@
+import { createStore } from 'redux';
+import reducers, { defaultState } from 'Store/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/src/UI/Shared/Styles/clickable.less b/frontend/src/Styles/Mixins/clickable.css
similarity index 100%
rename from src/UI/Shared/Styles/clickable.less
rename to frontend/src/Styles/Mixins/clickable.css
diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css
new file mode 100644
index 000000000..b266846d4
--- /dev/null
+++ b/frontend/src/Styles/Mixins/cover.css
@@ -0,0 +1,8 @@
+.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..971f96b13
--- /dev/null
+++ b/frontend/src/Styles/Mixins/linkOverlay.css
@@ -0,0 +1,11 @@
+.linkOverlay {
+ composes: cover from 'Styles/Mixins/cover.css';
+
+ 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..6a982646a
--- /dev/null
+++ b/frontend/src/Styles/Mixins/scroller.css
@@ -0,0 +1,26 @@
+.scrollbar {
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+}
+
+.scrollbarTrack {
+ &&::-webkit-scrollbar-track {
+ background-color: transparent;
+ }
+}
+
+.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..aa157178f
--- /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.
+ */
+
+.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..d045f2c30
--- /dev/null
+++ b/frontend/src/Styles/Variables/colors.js
@@ -0,0 +1,170 @@
+module.exports = {
+ defaultColor: '#333',
+ disabledColor: '#999',
+ black: '#000',
+ white: '#fff',
+ primaryColor: '#0b8750',
+ selectedColor: '#f9be03',
+ successColor: '#27c24c',
+ dangerColor: '#f05050',
+ warningColor: '#ffa500',
+ infoColor: '#00A65B',
+ purple: '#7a43b6',
+ nzbdronePurple: '#7932ea',
+ nzbdronePink: '#f43565',
+ sonarrBlue: '#00A65B',
+ helpTextColor: '#909293',
+ gray: '#adadad',
+
+ // Theme Colors
+
+ themeBlue: '#00A65B',
+ themeRed: '#c4273c',
+ themeDarkColor: '#216044',
+ themeLightColor: '#216044',
+
+ torrentColor: '#00853d',
+ usenetColor: '#17b1d9',
+
+ // Links
+ defaultLinkHoverColor: '#fff',
+ linkColor: '#0b8750',
+ linkHoverColor: '#1b72e2',
+
+ // Sidebar
+
+ sidebarColor: '#e1e2e3',
+ sidebarBackgroundColor: '#216044',
+ sidebarActiveBackgroundColor: '#353535',
+
+ // Toolbar
+ toolbarColor: '#e1e2e3',
+ toolbarBackgroundColor: '#216044',
+ toolbarMenuItemBackgroundColor: '#4D8069',
+ toolbarMenuItemHoverBackgroundColor: '#216044',
+ 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)',
+
+ //
+ // Buttons
+
+ defaultBackgroundColor: '#fff',
+ defaultBorderColor: '#eaeaea',
+ defaultHoverBackgroundColor: '#f5f5f5',
+ defaultHoverBorderColor: '#d6d6d6;',
+
+ primaryBackgroundColor: '#0b8750',
+ primaryBorderColor: '#216044',
+ primaryHoverBackgroundColor: '#097948',
+ primaryHoverBorderColor: '#1D563D;',
+
+ successBackgroundColor: '#27c24c',
+ successBorderColor: '#26be4a',
+ successHoverBackgroundColor: '#24b145',
+ successHoverBorderColor: '#1f9c3d;',
+
+ warningBackgroundColor: '#ff902b',
+ warningBorderColor: '#ff8d26',
+ warningHoverBackgroundColor: '#ff8517',
+ warningHoverBorderColor: '#fc7800;',
+
+ dangerBackgroundColor: '#f05050',
+ dangerBorderColor: '#f04b4b',
+ dangerHoverBackgroundColor: '#ee3d3d',
+ dangerHoverBorderColor: '#ec2626;',
+
+ iconButtonHoverColor: '#666',
+
+ //
+ // Modal
+
+ modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)',
+ modalBackgroundColor: '#fff',
+ modalCloseButtonHoverColor: '#888',
+
+ //
+ // Menu
+ menuItemColor: '#e1e2e3',
+ menuItemHoverColor: '#fbfcfc',
+
+ //
+ // Toolbar
+
+ toobarButtonHoverColor: '#00A65B',
+ toobarButtonSelectedColor: '#00A65B',
+
+ //
+ // 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: '#0b8750',
+
+ //
+ // 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: '#216044',
+ 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..fed51a1df
--- /dev/null
+++ b/frontend/src/Styles/Variables/dimensions.js
@@ -0,0 +1,43 @@
+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',
+
+ // Form
+ formGroupSmallWidth: '650px',
+ formGroupMediumWidth: '800px',
+ formGroupLargeWidth: '1200px',
+ formLabelWidth: '250px',
+ formLabelRightMarginWidth: '20px',
+
+ // Drag
+ dragHandleWidth: '40px',
+
+ // Progress Bar
+ progressBarSmallHeight: '5px',
+ progressBarMediumHeight: '15px',
+ progressBarLargeHeight: '20px',
+
+ // Jump Bar
+ jumpBarItemHeight: '25px'
+
+ // Series
+
+};
diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js
new file mode 100644
index 000000000..c4060b661
--- /dev/null
+++ b/frontend/src/Styles/Variables/fonts.js
@@ -0,0 +1,11 @@
+module.exports = {
+ // Families
+ defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
+ monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
+
+ // Sizes
+ extraSmallFontSize: '11px',
+ smallFontSize: '12px',
+ defaultFontSize: '14px',
+ largeFontSize: '16px'
+};
diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css
new file mode 100644
index 000000000..1d77911f9
--- /dev/null
+++ b/frontend/src/Styles/globals.css
@@ -0,0 +1,4 @@
+@import '~normalize.css/normalize.css';
+@import 'scaffolding.css';
+@import '../Content/Fonts/fonts.css';
+@import '../Content/Fonts/font-awesome.css';
diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css
new file mode 100644
index 000000000..79786d5f9
--- /dev/null
+++ b/frontend/src/Styles/scaffolding.css
@@ -0,0 +1,42 @@
+* {
+ box-sizing: border-box;
+}
+*:before,
+*:after {
+ box-sizing: border-box;
+}
+
+*:focus {
+ outline: none;
+}
+
+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/Backups.css b/frontend/src/System/Backup/Backups.css
new file mode 100644
index 000000000..fb280312c
--- /dev/null
+++ b/frontend/src/System/Backup/Backups.css
@@ -0,0 +1,5 @@
+.type {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js
new file mode 100644
index 000000000..02b8c3166
--- /dev/null
+++ b/frontend/src/System/Backup/Backups.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+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 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 styles from './Backups.css';
+
+const columns = [
+ {
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'time',
+ label: 'Time',
+ isVisible: true
+ }
+];
+
+class Backups extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items,
+ backupExecuting,
+ onBackupPress
+ } = this.props;
+
+ const hasBackups = !isFetching && items.length > 0;
+ const noBackups = !isFetching && !items.length;
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ noBackups &&
+ No backups are available
+ }
+
+ {
+ hasBackups &&
+
+
+ {
+ items.map((item) => {
+ const {
+ id,
+ type,
+ name,
+ path,
+ time
+ } = item;
+
+ let iconClassName = icons.SCHEDULED;
+ let iconTooltip = 'Scheduled';
+
+ if (type === 'manual') {
+ iconClassName = icons.INTERACTIVE;
+ iconTooltip = 'Manual';
+ } else if (item === 'update') {
+ iconClassName = icons.UPDATE;
+ iconTooltip = 'Before update';
+ }
+
+ return (
+
+
+ {
+
+ }
+
+
+
+
+ {name}
+
+
+
+
+
+ );
+ })
+ }
+
+
+ }
+
+
+ );
+ }
+
+}
+
+Backups.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ backupExecuting: PropTypes.bool.isRequired,
+ onBackupPress: 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..f6d5469eb
--- /dev/null
+++ b/frontend/src/System/Backup/BackupsConnector.js
@@ -0,0 +1,81 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { fetchBackups } 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,
+ createCommandsSelector(),
+ (backups, commands) => {
+ const {
+ isFetching,
+ items
+ } = backups;
+
+ const backupExecuting = _.some(commands, { name: commandNames.BACKUP });
+
+ return {
+ isFetching,
+ items,
+ backupExecuting
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchBackups,
+ executeCommand
+};
+
+class BackupsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchBackups();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.backupExecuting && !this.props.backupExecuting) {
+ this.props.fetchBackups();
+ }
+ }
+
+ //
+ // Listeners
+
+ onBackupPress = () => {
+ this.props.executeCommand({
+ name: commandNames.BACKUP
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BackupsConnector.propTypes = {
+ backupExecuting: PropTypes.bool.isRequired,
+ fetchBackups: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(BackupsConnector);
diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js
new file mode 100644
index 000000000..7ed1f6cf1
--- /dev/null
+++ b/frontend/src/System/Events/LogsTable.js
@@ -0,0 +1,176 @@
+import PropTypes from 'prop-types';
+import React, { Component } 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 FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import MenuContent from 'Components/Menu/MenuContent';
+import LogsTableRow from './LogsTableRow';
+
+class LogsTable extends Component {
+
+ //
+ // Listeners
+
+ onFilterMenuItemPress = (filterKey, filterValue) => {
+ this.props.onFilterSelect(filterKey, filterValue);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ filterKey,
+ filterValue,
+ totalRecords,
+ clearLogExecuting,
+ onRefreshPress,
+ onClearLogsPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+ Info
+
+
+
+ Warn
+
+
+
+ Error
+
+
+
+
+
+
+
+ {
+ 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,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.string,
+ 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..428ea90fd
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableConnector.js
@@ -0,0 +1,130 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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,
+ createCommandsSelector(),
+ (logs, commands) => {
+ const clearLogExecuting = _.some(commands, { name: commandNames.CLEAR_LOGS });
+
+ 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 = (filterKey, filterValue) => {
+ this.props.setLogsFilter({ filterKey, filterValue });
+ }
+
+ 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..9c54e5289
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.js
@@ -0,0 +1,150 @@
+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 = () => {
+ 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 (
+
+ );
+ }
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+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..16d8d23b9
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesConnector.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 combinePath from 'Utilities/String/combinePath';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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,
+ createCommandsSelector(),
+ (logFiles, status, commands) => {
+ const {
+ isFetching,
+ items
+ } = logFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ const deleteFilesExecuting = _.some(commands, { name: commandNames.DELETE_LOG_FILES });
+
+ 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..d49b82152
--- /dev/null
+++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.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 combinePath from 'Utilities/String/combinePath';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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,
+ createCommandsSelector(),
+ (updateLogFiles, status, commands) => {
+ const {
+ isFetching,
+ items
+ } = updateLogFiles;
+
+ const deleteFilesExecuting = _.some(commands, { name: commandNames.DELETE_UPDATE_LOG_FILES });
+
+ 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.js b/frontend/src/System/Status/About/About.js
new file mode 100644
index 000000000..51c0afde6
--- /dev/null
+++ b/frontend/src/System/Status/About/About.js
@@ -0,0 +1,71 @@
+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';
+
+class About extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ version,
+ isMonoRuntime,
+ runtimeVersion,
+ appData,
+ startupPath,
+ mode
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isMonoRuntime &&
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+About.propTypes = {
+ version: PropTypes.string,
+ isMonoRuntime: PropTypes.bool,
+ runtimeVersion: PropTypes.string,
+ appData: PropTypes.string,
+ startupPath: PropTypes.string,
+ mode: PropTypes.string
+};
+
+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..8d5c2ce0f
--- /dev/null
+++ b/frontend/src/System/Status/About/AboutConnector.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 { fetchStatus } from 'Store/Actions/systemActions';
+import About from './About';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return {
+ ...status.item
+ };
+ }
+ );
+}
+
+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/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..d2c10706e
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js
@@ -0,0 +1,122 @@
+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..629971f6d
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.js
@@ -0,0 +1,170 @@
+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 Link from 'Components/Link/Link';
+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 (
+
+ Settings
+
+ );
+ case 'DownloadClientCheck':
+ case 'ImportMechanismCheck':
+ return (
+
+ Settings
+
+ );
+ case 'RootFolderCheck':
+ return (
+
+
+ Series Editor
+
+
+ );
+ case 'UpdateCheck':
+ return (
+
+ Updates
+
+ );
+ default:
+ return;
+ }
+}
+
+const columns = [
+ {
+ className: styles.status,
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ isVisible: true
+ },
+ {
+ name: 'wikiLink',
+ label: 'Wiki',
+ isVisible: true
+ },
+ {
+ name: 'internalLink',
+ 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);
+
+ return (
+
+
+
+
+
+ {item.message}
+
+
+
+ Wiki
+
+
+
+
+ {
+ internalLink
+ }
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+Health.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.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..67f6a39dc
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthConnector.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 { fetchHealth } from 'Store/Actions/systemActions';
+import Health from './Health';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.health,
+ (health) => {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = health;
+
+ return {
+ isFetching,
+ isPopulated,
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHealth
+};
+
+class HealthConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchHealth();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HealthConnector.propTypes = {
+ fetchHealth: 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..b3afec021
--- /dev/null
+++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js
@@ -0,0 +1,69 @@
+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
+
+ lidarr.audio
+
+
+ Wiki
+
+ wiki.lidarr.audio
+
+
+ Reddit
+
+ Lidarr
+
+
+ Discord
+
+ #lidarr on Discord
+
+
+ Donations
+
+ Donate to Lidarr
+
+
+ Donations (Sonarr)
+
+ Donate to Sonarr
+
+
+ Source
+
+ github.com/Lidarr/Lidarr
+
+
+ Feature Requests
+
+ github.com/Lidarr/Lidarr/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/TaskRow.css b/frontend/src/System/Tasks/TaskRow.css
new file mode 100644
index 000000000..dc83cfd69
--- /dev/null
+++ b/frontend/src/System/Tasks/TaskRow.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/TaskRow.js b/frontend/src/System/Tasks/TaskRow.js
new file mode 100644
index 000000000..f118842a7
--- /dev/null
+++ b/frontend/src/System/Tasks/TaskRow.js
@@ -0,0 +1,94 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React 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 './TaskRow.css';
+
+function TaskRow(props) {
+ const {
+ name,
+ interval,
+ lastExecution,
+ nextExecution,
+ isExecuting,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ onExecutePress
+ } = props;
+
+ const disabled = interval === 0;
+ const executeNow = !disabled && moment().isAfter(nextExecution);
+ const hasNextExecutionTime = !disabled && !executeNow;
+ const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1');
+
+ return (
+
+ {name}
+
+ {disabled ? 'disabled' : duration}
+
+
+
+ {showRelativeDates ? moment(lastExecution).fromNow() : formatDate(lastExecution, shortDateFormat)}
+
+
+ {
+ disabled &&
+ -
+ }
+
+ {
+ executeNow &&
+ now
+ }
+
+ {
+ hasNextExecutionTime &&
+
+ {showRelativeDates ? moment(nextExecution).fromNow() : formatDate(nextExecution, shortDateFormat)}
+
+ }
+
+
+
+
+
+ );
+}
+
+TaskRow.propTypes = {
+ name: PropTypes.string.isRequired,
+ interval: PropTypes.number.isRequired,
+ lastExecution: PropTypes.string.isRequired,
+ nextExecution: PropTypes.string.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 TaskRow;
diff --git a/frontend/src/System/Tasks/TaskRowConnector.js b/frontend/src/System/Tasks/TaskRowConnector.js
new file mode 100644
index 000000000..035364034
--- /dev/null
+++ b/frontend/src/System/Tasks/TaskRowConnector.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 { findCommand } 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 TaskRow from './TaskRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { taskName }) => taskName,
+ createCommandsSelector(),
+ createUISettingsSelector(),
+ (taskName, commands, uiSettings) => {
+ const isExecuting = !!findCommand(commands, { name: taskName });
+
+ return {
+ isExecuting,
+ 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 TaskRowConnector 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 (
+
+ );
+ }
+}
+
+TaskRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ dispatchFetchTask: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TaskRowConnector);
diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js
new file mode 100644
index 000000000..ae2d75dbb
--- /dev/null
+++ b/frontend/src/System/Tasks/Tasks.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TaskRowConnector from './TaskRowConnector';
+
+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
+ }
+];
+
+class Tasks extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = this.props;
+
+ return (
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+ );
+ }
+
+}
+
+Tasks.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default Tasks;
diff --git a/frontend/src/System/Tasks/TasksConnector.js b/frontend/src/System/Tasks/TasksConnector.js
new file mode 100644
index 000000000..492040674
--- /dev/null
+++ b/frontend/src/System/Tasks/TasksConnector.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 Tasks from './Tasks';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.tasks,
+ (tasks) => {
+ return tasks;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchTasks
+};
+
+class TasksConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchTasks();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TasksConnector.propTypes = {
+ fetchTasks: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(TasksConnector);
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..1e08c67af
--- /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..c7561fdd2
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.css
@@ -0,0 +1,46 @@
+.upToDate {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+.upToDateIcon {
+ color: #37bc9b;
+ font-size: 30px;
+}
+
+.upToDateMessage {
+ padding-left: 5px;
+ font-size: 18px;
+ line-height: 30px;
+}
+
+.update {
+ margin-top: 20px;
+}
+
+.info {
+ display: flex;
+ margin-bottom: 10px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+ line-height: 21px;
+}
+
+.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..3aa2037a4
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.js
@@ -0,0 +1,149 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { 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 {
+ 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 &&
+
+ }
+
+ {
+ noUpdates &&
+ No updates are available
+ }
+
+ {
+ hasUpdateToInstall &&
+
+ Install Latest
+
+ }
+
+ {
+ noUpdateToInstall &&
+
+
+
+ The latest version of Sonarr is already installed
+
+
+ }
+
+ {
+ 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 = {
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object.isRequired,
+ 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..0ac6cf239
--- /dev/null
+++ b/frontend/src/System/Updates/UpdatesConnector.js
@@ -0,0 +1,74 @@
+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 {
+ isPopulated,
+ error,
+ items
+ } = updates;
+
+ return {
+ 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/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..e64737188
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandComplete.js
@@ -0,0 +1,9 @@
+function isCommandComplete(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.state === '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..4e2e6d8c4
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandExecuting.js
@@ -0,0 +1,9 @@
+function isCommandExecuting(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.state === 'queued' || command.state === '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..f48d790e3
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandFailed.js
@@ -0,0 +1,12 @@
+function isCommandFailed(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.state === 'failed' ||
+ command.state === 'aborted' ||
+ command.state === 'cancelled' ||
+ command.state === '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/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..0d5ab4f58
--- /dev/null
+++ b/frontend/src/Utilities/Date/getRelativeDate.js
@@ -0,0 +1,40 @@
+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;
+ }
+
+ if (!showRelativeDates) {
+ return moment(date).format(shortDateFormat);
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday';
+ }
+
+ if (isToday(date)) {
+ if (timeForToday && timeFormat) {
+ return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+ }
+
+ 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..1d83d1b72
--- /dev/null
+++ b/frontend/src/Utilities/Date/isTomorrow.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isTomrrow(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment().add(1, 'day'), 'day');
+}
+
+export default isTomrrow;
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..91cc61cd9
--- /dev/null
+++ b/frontend/src/Utilities/Episode/updateEpisodes.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { update } from 'Store/Actions/baseActions';
+
+function updateEpisodes(dispatch, 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;
+ }, []);
+
+ dispatch(update({ section, data }));
+}
+
+export default updateEpisodes;
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/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js
new file mode 100644
index 000000000..610d3fece
--- /dev/null
+++ b/frontend/src/Utilities/ResolutionUtility.js
@@ -0,0 +1,26 @@
+var $ = require('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/getMonitoringOptions.js b/frontend/src/Utilities/Series/getMonitoringOptions.js
new file mode 100644
index 000000000..d36b7dcfd
--- /dev/null
+++ b/frontend/src/Utilities/Series/getMonitoringOptions.js
@@ -0,0 +1,70 @@
+import _ from 'lodash';
+
+function monitorSeasons(seasons, startingSeason) {
+ seasons.forEach((season) => {
+ if (season.seasonNumber >= startingSeason) {
+ season.monitored = true;
+ } else {
+ season.monitored = false;
+ }
+ });
+}
+
+function getMonitoringOptions(seasons, monitor) {
+ if (!seasons.length) {
+ return {
+ seasons: [],
+ options: {
+ ignoreEpisodesWithFiles: false,
+ ignoreEpisodesWithoutFiles: false
+ }
+ };
+ }
+
+ const firstSeason = _.minBy(_.reject(seasons, { seasonNumber: 0 }), 'seasonNumber').seasonNumber;
+ const lastSeason = _.maxBy(seasons, 'seasonNumber').seasonNumber;
+
+ monitorSeasons(seasons, firstSeason);
+
+ const monitoringOptions = {
+ ignoreEpisodesWithFiles: false,
+ ignoreEpisodesWithoutFiles: false
+ };
+
+ switch (monitor) {
+ case 'future':
+ monitoringOptions.ignoreEpisodesWithFiles = true;
+ monitoringOptions.ignoreEpisodesWithoutFiles = true;
+ break;
+ case 'latest':
+ monitorSeasons(seasons, lastSeason);
+ break;
+ case 'first':
+ monitorSeasons(seasons, lastSeason + 1);
+ _.find(seasons, { seasonNumber: firstSeason }).monitored = true;
+ break;
+ case 'missing':
+ monitoringOptions.ignoreEpisodesWithFiles = true;
+ break;
+ case 'existing':
+ monitoringOptions.ignoreEpisodesWithoutFiles = true;
+ break;
+ case 'none':
+ monitorSeasons(seasons, lastSeason + 1);
+ break;
+ default:
+ break;
+ }
+
+ return {
+ seasons: _.map(seasons, (season) => {
+ return _.pick(season, [
+ 'seasonNumber',
+ 'monitored'
+ ]);
+ }),
+ options: monitoringOptions
+ };
+}
+
+export default getMonitoringOptions;
diff --git a/frontend/src/Utilities/Series/getNewSeries.js b/frontend/src/Utilities/Series/getNewSeries.js
new file mode 100644
index 000000000..037827b50
--- /dev/null
+++ b/frontend/src/Utilities/Series/getNewSeries.js
@@ -0,0 +1,34 @@
+import getMonitoringOptions from 'Utilities/Series/getMonitoringOptions';
+
+function getNewSeries(series, payload) {
+ const {
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ seriesType,
+ albumFolder,
+ tags,
+ searchForMissingEpisodes = false
+ } = payload;
+
+ //const {
+ //seasons,
+ //options: addOptions
+ //} = getMonitoringOptions(series.seasons, monitor);
+
+ //addOptions.searchForMissingEpisodes = searchForMissingEpisodes;
+ //series.addOptions = addOptions;
+ //series.seasons = seasons;
+ series.monitored = true;
+ series.qualityProfileId = qualityProfileId;
+ series.languageProfileId = languageProfileId;
+ series.rootFolderPath = rootFolderPath;
+ //series.seriesType = seriesType;
+ series.albumFolder = albumFolder;
+ 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/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js
new file mode 100644
index 000000000..7d6e5cb1d
--- /dev/null
+++ b/frontend/src/Utilities/State/getProviderState.js
@@ -0,0 +1,30 @@
+import _ from 'lodash';
+
+function getProviderState(payload, getState, getFromState) {
+ const id = payload.id;
+ const state = getFromState(getState());
+ const pendingChanges = Object.assign({}, state.pendingChanges);
+ 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..c188d9eaa
--- /dev/null
+++ b/frontend/src/Utilities/State/getSectionState.js
@@ -0,0 +1,9 @@
+function getSectionState(state, section) {
+ 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..c7407257d
--- /dev/null
+++ b/frontend/src/Utilities/State/updateSectionState.js
@@ -0,0 +1,9 @@
+function updateSectionState(state, section, newState) {
+ 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/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..531e4df68
--- /dev/null
+++ b/frontend/src/Utilities/String/titleCase.js
@@ -0,0 +1,11 @@
+function titleCase(input) {
+ if (!input) {
+ return '';
+ }
+
+ return input.replace(/\w\S*/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..23bc8e20d
--- /dev/null
+++ b/frontend/src/Utilities/Table/areAllSelected.js
@@ -0,0 +1,17 @@
+export default function aareAllSelected(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..4b19dc268
--- /dev/null
+++ b/frontend/src/Utilities/Table/toggleSelected.js
@@ -0,0 +1,26 @@
+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 (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/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/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..bb62d4575
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
@@ -0,0 +1,285 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import CutoffUnmetRow from './CutoffUnmetRow';
+
+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);
+ });
+ }s;
+ }
+
+ //
+ // 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 selected = this.getSelectedIds();
+
+ this.props.onToggleSelectedPress(selected);
+ }
+
+ 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,
+ columns,
+ totalRecords,
+ isSearchingForEpisodes,
+ isSearchingForCutoffUnmetEpisodes,
+ isSaving,
+ filterKey,
+ filterValue,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllCutoffUnmetModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monitored
+
+
+
+ Unmonitored
+
+
+
+
+
+
+
+ {
+ 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,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForEpisodes: PropTypes.bool.isRequired,
+ isSearchingForCutoffUnmetEpisodes: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ onToggleSelectedPress: 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..53ef15430
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
@@ -0,0 +1,180 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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,
+ createCommandsSelector(),
+ (cutoffUnmet, commands) => {
+ const isSearchingForEpisodes = _.some(commands, { name: commandNames.EPISODE_SEARCH });
+ const isSearchingForCutoffUnmetEpisodes = _.some(commands, { name: commandNames.CUTOFF_UNMET_EPISODE_SEARCH });
+
+ return {
+ isSearchingForEpisodes,
+ isSearchingForCutoffUnmetEpisodes,
+ isSaving: _.some(cutoffUnmet.items, { isSaving: true }),
+ ...cutoffUnmet
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails,
+ fetchEpisodeFiles,
+ clearEpisodeFiles
+};
+
+class CutoffUnmetConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.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() {
+ this.props.clearCutoffUnmet();
+ this.props.clearQueueDetails();
+ this.props.clearEpisodeFiles();
+ }
+
+ //
+ // 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 = (filterKey, filterValue) => {
+ this.props.setCutoffUnmetFilter({ filterKey, filterValue });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setCutoffUnmetTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.EPISODE_SEARCH,
+ episodeIds: selected
+ });
+ }
+
+ onToggleSelectedPress = (selected) => {
+ const {
+ filterKey,
+ filterValue
+ } = this.props;
+
+ this.props.batchToggleCutoffUnmetEpisodes({
+ episodeIds: selected,
+ monitored: filterKey !== 'monitored' || !filterValue
+ });
+ }
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CUTOFF_UNMET_EPISODE_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CutoffUnmetConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string.isRequired,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ 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,
+ batchToggleCutoffUnmetEpisodes: 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 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..32bc95650
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
@@ -0,0 +1,169 @@
+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 ArtistNameLink from 'Artist/ArtistNameLink';
+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,
+ 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 (
+
+ );
+ }
+ })
+ }
+
+ );
+}
+
+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,
+ 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/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js
new file mode 100644
index 000000000..35c2143de
--- /dev/null
+++ b/frontend/src/Wanted/Missing/Missing.js
@@ -0,0 +1,307 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import MissingRow from './MissingRow';
+
+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
+
+ 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 selected = this.getSelectedIds();
+
+ this.props.onToggleSelectedPress(selected);
+ }
+
+ 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,
+ columns,
+ totalRecords,
+ isSearchingForEpisodes,
+ isSearchingForMissingEpisodes,
+ isSaving,
+ filterKey,
+ filterValue,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllMissingModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monitored
+
+
+
+ Unmonitored
+
+
+
+
+
+
+
+ {
+ 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,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForEpisodes: PropTypes.bool.isRequired,
+ isSearchingForMissingEpisodes: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ onToggleSelectedPress: 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..97fb61897
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingConnector.js
@@ -0,0 +1,168 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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,
+ createCommandsSelector(),
+ (missing, commands) => {
+ const isSearchingForEpisodes = _.some(commands, { name: commandNames.EPISODE_SEARCH });
+ const isSearchingForMissingEpisodes = _.some(commands, { name: commandNames.MISSING_EPISODE_SEARCH });
+
+ return {
+ isSearchingForEpisodes,
+ isSearchingForMissingEpisodes,
+ isSaving: _.some(missing.items, { isSaving: true }),
+ ...missing
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class MissingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.gotoMissingFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const episodeIds = selectUniqueIds(this.props.items, 'id');
+ this.props.fetchQueueDetails({ episodeIds });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearMissing();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // 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 = (filterKey, filterValue) => {
+ this.props.setMissingFilter({ filterKey, filterValue });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setMissingTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoMissingFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.EPISODE_SEARCH,
+ episodeIds: selected
+ });
+ }
+
+ onToggleSelectedPress = (selected) => {
+ const {
+ filterKey,
+ filterValue
+ } = this.props;
+
+ this.props.batchToggleMissingEpisodes({
+ episodeIds: selected,
+ monitored: filterKey !== 'monitored' || !filterValue
+ });
+ }
+
+ onSearchAllMissingPress = () => {
+ this.props.executeCommand({
+ name: commandNames.MISSING_EPISODE_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MissingConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string.isRequired,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ 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,
+ batchToggleMissingEpisodes: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default 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..5eae99673
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.js
@@ -0,0 +1,155 @@
+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 ArtistNameLink from 'Artist/ArtistNameLink';
+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,
+ 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 === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+ })
+ }
+
+ );
+}
+
+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,
+ 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/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..b875687b2
--- /dev/null
+++ b/frontend/src/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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..8acc1897d
--- /dev/null
+++ b/frontend/src/jQuery/jquery.ajax.js
@@ -0,0 +1,47 @@
+const $ = require('JsLibraries/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;
+}
+
+module.exports = function(jQuery) {
+ const originalAjax = jQuery.ajax;
+ jQuery.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..80ad76710
--- /dev/null
+++ b/frontend/src/login.html
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login - Sonarr
+
+
+
+
+
+
+
+
+
+
+
+
+ SIGN IN TO CONTINUE
+
+
+
+
+
+
+
+ ©
+ 2010-2017
+ -
+ Sonarr
+
+
+
+
+
+
+
diff --git a/src/UI/oauth.html b/frontend/src/oauth.html
similarity index 84%
rename from src/UI/oauth.html
rename to frontend/src/oauth.html
index fe6ddf864..16a34dbf3 100644
--- a/src/UI/oauth.html
+++ b/frontend/src/oauth.html
@@ -2,7 +2,7 @@
- oauth landing page
+ OAuth landing page
@@ -10,4 +10,4 @@
Shouldn't see this
-
\ No newline at end of file
+
diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js
new file mode 100644
index 000000000..9ee333095
--- /dev/null
+++ b/frontend/src/polyfills.js
@@ -0,0 +1,39 @@
+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;
+ var lastIndex = this.lastIndexOf(searchString);
+ return lastIndex !== -1 && lastIndex === position;
+ }
+ });
+}
+
+if (!('contains' in String.prototype)) {
+ String.prototype.contains = function(str, startIndex) {
+ return -1 !== String.prototype.indexOf.call(this, str, startIndex);
+ };
+}
diff --git a/frontend/src/preload.js b/frontend/src/preload.js
new file mode 100644
index 000000000..f8ec3c40e
--- /dev/null
+++ b/frontend/src/preload.js
@@ -0,0 +1 @@
+__webpack_public_path__ = `${window.Sonarr.urlBase}/`;
diff --git a/frontend/src/vendor.js b/frontend/src/vendor.js
new file mode 100644
index 000000000..2b08817be
--- /dev/null
+++ b/frontend/src/vendor.js
@@ -0,0 +1,5 @@
+/* Base */
+// require('jquery');
+require('lodash');
+require('moment');
+// require('signalR');
diff --git a/gulpFile.js b/gulpFile.js
index 28dc9b0f1..73636a918 100644
--- a/gulpFile.js
+++ b/gulpFile.js
@@ -1 +1 @@
-require('./gulp/gulpFile.js');
+require('./frontend/gulp/gulpFile.js');
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
new file mode 100644
index 000000000..8e12daea1
--- /dev/null
+++ b/npm-shrinkwrap.json
@@ -0,0 +1,5242 @@
+{
+ "name": "lidarr",
+ "version": "1.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"
+ },
+ "add-px-to-style": {
+ "version": "1.0.0",
+ "from": "add-px-to-style@1.0.0",
+ "resolved": "https://registry.npmjs.org/add-px-to-style/-/add-px-to-style-1.0.0.tgz"
+ },
+ "ajv": {
+ "version": "4.11.8",
+ "from": "ajv@>=4.7.0 <5.0.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz"
+ },
+ "ajv-keywords": {
+ "version": "1.5.1",
+ "from": "ajv-keywords@>=1.0.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-1.5.1.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-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"
+ },
+ "caniuse-lite": {
+ "version": "1.0.30000708",
+ "from": "caniuse-lite@>=1.0.30000697 <2.0.0",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000708.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"
+ },
+ "clean-css": {
+ "version": "4.1.2",
+ "from": "clean-css@4.1.2",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.2.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"
+ },
+ "clipboard": {
+ "version": "1.7.1",
+ "from": "clipboard@latest",
+ "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-1.7.1.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"
+ },
+ "co": {
+ "version": "4.6.0",
+ "from": "co@>=4.6.0 <5.0.0",
+ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.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",
+ "dependencies": {
+ "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"
+ }
+ }
+ },
+ "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.5.0",
+ "from": "convert-source-map@>=1.1.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.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"
+ },
+ "create-react-class": {
+ "version": "15.6.0",
+ "from": "create-react-class@>=15.6.0 <16.0.0",
+ "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.0.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"
+ },
+ "fbjs": {
+ "version": "0.8.12",
+ "from": "fbjs@^0.8.9",
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.12.tgz"
+ },
+ "js-tokens": {
+ "version": "3.0.1",
+ "from": "js-tokens@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.1.tgz"
+ },
+ "loose-envify": {
+ "version": "1.3.1",
+ "from": "loose-envify@>=1.3.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.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"
+ },
+ "delegate": {
+ "version": "3.1.3",
+ "from": "delegate@>=3.1.2 <4.0.0",
+ "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.1.3.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.4.0",
+ "from": "dnd-core@>=2.4.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.4.0.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-css": {
+ "version": "2.1.0",
+ "from": "dom-css@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/dom-css/-/dom-css-2.1.0.tgz"
+ },
+ "dom-helpers": {
+ "version": "3.2.1",
+ "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.1.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"
+ },
+ "electron-to-chromium": {
+ "version": "1.3.16",
+ "from": "electron-to-chromium@>=1.3.16 <2.0.0",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.16.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.2",
+ "from": "exenv@>=1.2.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.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.12",
+ "from": "fbjs@>=0.8.4 <0.9.0",
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.12.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"
+ },
+ "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"
+ },
+ "good-listener": {
+ "version": "1.2.2",
+ "from": "good-listener@>=1.2.2 <2.0.0",
+ "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.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",
+ "dependencies": {
+ "semver": {
+ "version": "4.3.6",
+ "from": "semver@>=4.1.0 <5.0.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.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-clean-css": {
+ "version": "3.3.1",
+ "from": "gulp-clean-css@3.3.1",
+ "resolved": "https://registry.npmjs.org/gulp-clean-css/-/gulp-clean-css-3.3.1.tgz",
+ "dependencies": {
+ "dateformat": {
+ "version": "2.0.0",
+ "from": "dateformat@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.0.0.tgz"
+ },
+ "gulp-util": {
+ "version": "3.0.8",
+ "from": "gulp-util@3.0.8",
+ "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.8.tgz"
+ },
+ "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-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-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"
+ },
+ "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": "4.6.3",
+ "from": "history@latest",
+ "resolved": "https://registry.npmjs.org/history/-/history-4.6.3.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"
+ },
+ "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"
+ },
+ "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-directory": {
+ "version": "0.3.1",
+ "from": "is-directory@>=0.3.1 <0.4.0",
+ "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.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",
+ "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"
+ }
+ }
+ },
+ "jdu": {
+ "version": "1.0.0",
+ "from": "https://registry.npmjs.org/jdu/-/jdu-1.0.0.tgz",
+ "resolved": "https://registry.npmjs.org/jdu/-/jdu-1.0.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-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"
+ }
+ }
+ },
+ "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.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"
+ },
+ "mathml-tag-names": {
+ "version": "2.0.1",
+ "from": "mathml-tag-names@>=2.0.1 <3.0.0",
+ "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.0.1.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-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"
+ }
+ }
+ },
+ "mobile-detect": {
+ "version": "1.3.6",
+ "from": "mobile-detect@latest",
+ "resolved": "https://registry.npmjs.org/mobile-detect/-/mobile-detect-1.3.6.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"
+ },
+ "mousetrap": {
+ "version": "1.6.0",
+ "from": "mousetrap@latest",
+ "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.0.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.1",
+ "from": "object-assign@>=4.0.1 <5.0.0",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.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.0 <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-to-regexp": {
+ "version": "1.7.0",
+ "from": "path-to-regexp@>=1.5.3 <2.0.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.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"
+ }
+ }
+ },
+ "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"
+ },
+ "performance-now": {
+ "version": "2.1.0",
+ "from": "performance-now@>=2.1.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.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-sorting": {
+ "version": "3.0.1",
+ "from": "postcss-sorting@>=3.0.1 <4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-3.0.1.tgz",
+ "dependencies": {
+ "ansi-styles": {
+ "version": "3.2.0",
+ "from": "ansi-styles@^3.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz"
+ },
+ "chalk": {
+ "version": "2.0.1",
+ "from": "chalk@^2.0.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.0.1.tgz"
+ },
+ "color-convert": {
+ "version": "1.9.0",
+ "from": "color-convert@^1.9.0",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.0.tgz"
+ },
+ "has-flag": {
+ "version": "2.0.0",
+ "from": "has-flag@^2.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz"
+ },
+ "postcss": {
+ "version": "6.0.8",
+ "from": "postcss@^6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.8.tgz"
+ },
+ "supports-color": {
+ "version": "4.2.1",
+ "from": "supports-color@^4.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.2.1.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"
+ },
+ "prefix-style": {
+ "version": "2.0.1",
+ "from": "prefix-style@2.0.1",
+ "resolved": "https://registry.npmjs.org/prefix-style/-/prefix-style-2.0.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"
+ },
+ "prop-types": {
+ "version": "15.5.10",
+ "from": "prop-types@latest",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.5.10.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"
+ },
+ "fbjs": {
+ "version": "0.8.12",
+ "from": "fbjs@>=0.8.9 <0.9.0",
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.12.tgz"
+ },
+ "js-tokens": {
+ "version": "3.0.1",
+ "from": "js-tokens@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.1.tgz"
+ },
+ "loose-envify": {
+ "version": "1.3.1",
+ "from": "loose-envify@>=1.3.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.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": "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz",
+ "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"
+ },
+ "raf": {
+ "version": "3.3.2",
+ "from": "raf@>=3.1.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.3.2.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.6.1",
+ "from": "react@15.6.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-15.6.1.tgz",
+ "dependencies": {
+ "core-js": {
+ "version": "1.2.7",
+ "from": "core-js@^1.0.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz"
+ },
+ "fbjs": {
+ "version": "0.8.12",
+ "from": "fbjs@>=0.8.9 <0.9.0",
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.12.tgz"
+ }
+ }
+ },
+ "react-addons-shallow-compare": {
+ "version": "15.6.0",
+ "from": "react-addons-shallow-compare@15.6.0",
+ "resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.0.tgz"
+ },
+ "react-async-script": {
+ "version": "0.9.1",
+ "from": "react-async-script@0.9.1",
+ "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-0.9.1.tgz"
+ },
+ "react-autosuggest": {
+ "version": "9.3.0",
+ "from": "react-autosuggest@9.3.0",
+ "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-9.3.0.tgz"
+ },
+ "react-autowhatever": {
+ "version": "10.1.0",
+ "from": "react-autowhatever@>=10.1.0 <11.0.0",
+ "resolved": "https://registry.npmjs.org/react-autowhatever/-/react-autowhatever-10.1.0.tgz"
+ },
+ "react-custom-scrollbars": {
+ "version": "4.1.2",
+ "from": "react-custom-scrollbars@latest",
+ "resolved": "https://registry.npmjs.org/react-custom-scrollbars/-/react-custom-scrollbars-4.1.2.tgz"
+ },
+ "react-dnd": {
+ "version": "2.4.0",
+ "from": "react-dnd@2.4.0",
+ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.4.0.tgz"
+ },
+ "react-dnd-html5-backend": {
+ "version": "2.4.1",
+ "from": "react-dnd-html5-backend@2.4.1",
+ "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.4.1.tgz"
+ },
+ "react-document-title": {
+ "version": "2.0.3",
+ "from": "react-document-title@2.0.3",
+ "resolved": "https://registry.npmjs.org/react-document-title/-/react-document-title-2.0.3.tgz"
+ },
+ "react-dom": {
+ "version": "15.6.1",
+ "from": "react-dom@15.6.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.1.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"
+ },
+ "fbjs": {
+ "version": "0.8.12",
+ "from": "fbjs@>=0.8.9 <0.9.0",
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.12.tgz"
+ }
+ }
+ },
+ "react-google-recaptcha": {
+ "version": "0.9.6",
+ "from": "react-google-recaptcha@0.9.6",
+ "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-0.9.6.tgz"
+ },
+ "react-lazyload": {
+ "version": "2.2.7",
+ "from": "react-lazyload@2.2.7",
+ "resolved": "https://registry.npmjs.org/react-lazyload/-/react-lazyload-2.2.7.tgz"
+ },
+ "react-measure": {
+ "version": "1.4.7",
+ "from": "react-measure@1.4.7",
+ "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-1.4.7.tgz"
+ },
+ "react-portal": {
+ "version": "3.1.0",
+ "from": "react-portal@3.1.0",
+ "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-3.1.0.tgz"
+ },
+ "react-redux": {
+ "version": "5.0.5",
+ "from": "react-redux@5.0.5",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.5.tgz"
+ },
+ "react-router": {
+ "version": "4.1.1",
+ "from": "react-router@>=4.1.1 <5.0.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.1.1.tgz",
+ "dependencies": {
+ "invariant": {
+ "version": "2.2.2",
+ "from": "invariant@>=2.2.2 <3.0.0",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz"
+ },
+ "js-tokens": {
+ "version": "3.0.2",
+ "from": "js-tokens@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz"
+ },
+ "loose-envify": {
+ "version": "1.3.1",
+ "from": "loose-envify@>=1.3.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz"
+ }
+ }
+ },
+ "react-router-dom": {
+ "version": "4.1.1",
+ "from": "react-router-dom@latest",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.1.1.tgz",
+ "dependencies": {
+ "history": {
+ "version": "4.6.3",
+ "from": "history@>=4.5.1 <5.0.0",
+ "resolved": "https://registry.npmjs.org/history/-/history-4.6.3.tgz"
+ },
+ "js-tokens": {
+ "version": "3.0.2",
+ "from": "js-tokens@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz"
+ },
+ "loose-envify": {
+ "version": "1.3.1",
+ "from": "loose-envify@>=1.3.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz"
+ },
+ "react-router": {
+ "version": "4.1.1",
+ "from": "react-router@>=4.1.1 <5.0.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.1.1.tgz",
+ "dependencies": {
+ "invariant": {
+ "version": "2.2.2",
+ "from": "invariant@>=2.2.2 <3.0.0",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz"
+ }
+ }
+ }
+ }
+ },
+ "react-router-redux": {
+ "version": "5.0.0-alpha.6",
+ "from": "react-router-redux@next",
+ "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-5.0.0-alpha.6.tgz"
+ },
+ "react-side-effect": {
+ "version": "1.1.3",
+ "from": "react-side-effect@>=1.0.2 <2.0.0",
+ "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.3.tgz"
+ },
+ "react-slider": {
+ "version": "0.8.0",
+ "from": "react-slider@0.8.0",
+ "resolved": "https://registry.npmjs.org/react-slider/-/react-slider-0.8.0.tgz"
+ },
+ "react-tabs": {
+ "version": "1.1.0",
+ "from": "react-tabs@1.1.0",
+ "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-1.1.0.tgz"
+ },
+ "react-tag-autocomplete": {
+ "version": "5.4.0",
+ "from": "react-tag-autocomplete@5.4.0",
+ "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-5.4.0.tgz"
+ },
+ "react-tether": {
+ "version": "0.5.7",
+ "from": "react-tether@0.5.7",
+ "resolved": "https://registry.npmjs.org/react-tether/-/react-tether-0.5.7.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": "9.8.0",
+ "from": "react-virtualized@9.8.0",
+ "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.8.0.tgz",
+ "dependencies": {
+ "babel-runtime": {
+ "version": "6.23.0",
+ "from": "babel-runtime@>=6.11.6 <7.0.0",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz"
+ },
+ "js-tokens": {
+ "version": "3.0.2",
+ "from": "js-tokens@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.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.5",
+ "from": "regenerator-runtime@>=0.10.0 <0.11.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.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.2.9",
+ "from": "readable-stream@>=2.0.2 <3.0.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.9.tgz",
+ "dependencies": {
+ "string_decoder": {
+ "version": "1.0.0",
+ "from": "string_decoder@>=1.0.0 <1.1.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.0.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.7.0",
+ "from": "redux@3.7.0",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.0.tgz"
+ },
+ "redux-actions": {
+ "version": "2.0.3",
+ "from": "redux-actions@2.0.3",
+ "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-2.0.3.tgz",
+ "dependencies": {
+ "lodash-es": {
+ "version": "4.17.4",
+ "from": "lodash-es@>=4.17.4 <5.0.0",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.4.tgz"
+ }
+ }
+ },
+ "redux-batched-actions": {
+ "version": "0.2.0",
+ "from": "redux-batched-actions@0.2.0",
+ "resolved": "https://registry.npmjs.org/redux-batched-actions/-/redux-batched-actions-0.2.0.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": "3.0.1",
+ "from": "reselect@3.0.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz"
+ },
+ "resize-observer-polyfill": {
+ "version": "1.4.2",
+ "from": "resize-observer-polyfill@>=1.4.2 <2.0.0",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.4.2.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"
+ },
+ "resolve-pathname": {
+ "version": "2.1.0",
+ "from": "resolve-pathname@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.1.0.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",
+ "dependencies": {
+ "semver": {
+ "version": "4.3.6",
+ "from": "semver@>=4.3.1 <5.0.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.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"
+ },
+ "select": {
+ "version": "1.1.2",
+ "from": "select@>=1.1.2 <2.0.0",
+ "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz"
+ },
+ "semver": {
+ "version": "5.3.0",
+ "from": "semver@>=2.0.0 <3.0.0||>=3.0.0 <4.0.0||>=4.0.0 <5.0.0||>=5.0.0 <6.0.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.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": "1.0.1",
+ "from": "shallowequal@>=1.0.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.0.1.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.5 <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"
+ }
+ }
+ },
+ "stylelint-order": {
+ "version": "0.6.0",
+ "from": "stylelint-order@latest",
+ "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-0.6.0.tgz",
+ "dependencies": {
+ "ansi-regex": {
+ "version": "3.0.0",
+ "from": "ansi-regex@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz"
+ },
+ "ansi-styles": {
+ "version": "3.2.0",
+ "from": "ansi-styles@>=3.1.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz"
+ },
+ "autoprefixer": {
+ "version": "7.1.2",
+ "from": "autoprefixer@>=7.1.2 <8.0.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-7.1.2.tgz"
+ },
+ "balanced-match": {
+ "version": "1.0.0",
+ "from": "balanced-match@>=1.0.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz"
+ },
+ "brace-expansion": {
+ "version": "1.1.8",
+ "from": "brace-expansion@>=1.1.7 <2.0.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz"
+ },
+ "browserslist": {
+ "version": "2.2.2",
+ "from": "browserslist@>=2.1.5 <3.0.0",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.2.2.tgz"
+ },
+ "chalk": {
+ "version": "2.0.1",
+ "from": "chalk@>=2.0.1 <3.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.0.1.tgz"
+ },
+ "color-convert": {
+ "version": "1.9.0",
+ "from": "color-convert@>=1.9.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.0.tgz"
+ },
+ "cosmiconfig": {
+ "version": "2.2.2",
+ "from": "cosmiconfig@>=2.1.3 <3.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-2.2.2.tgz"
+ },
+ "debug": {
+ "version": "2.6.8",
+ "from": "debug@>=2.6.8 <3.0.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz"
+ },
+ "file-entry-cache": {
+ "version": "2.0.0",
+ "from": "file-entry-cache@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz"
+ },
+ "get-stdin": {
+ "version": "5.0.1",
+ "from": "get-stdin@>=5.0.1 <6.0.0",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz"
+ },
+ "glob": {
+ "version": "7.1.2",
+ "from": "glob@>=7.0.3 <8.0.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz"
+ },
+ "globby": {
+ "version": "6.1.0",
+ "from": "globby@>=6.1.0 <7.0.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz",
+ "dependencies": {
+ "pify": {
+ "version": "2.3.0",
+ "from": "pify@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
+ }
+ }
+ },
+ "has-flag": {
+ "version": "2.0.0",
+ "from": "has-flag@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz"
+ },
+ "html-tags": {
+ "version": "2.0.0",
+ "from": "html-tags@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-2.0.0.tgz"
+ },
+ "ignore": {
+ "version": "3.3.3",
+ "from": "ignore@>=3.3.3 <4.0.0",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.3.tgz"
+ },
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "from": "is-fullwidth-code-point@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz"
+ },
+ "known-css-properties": {
+ "version": "0.2.0",
+ "from": "known-css-properties@>=0.2.0 <0.3.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.2.0.tgz"
+ },
+ "micromatch": {
+ "version": "2.3.11",
+ "from": "micromatch@>=2.3.11 <3.0.0",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz"
+ },
+ "minimatch": {
+ "version": "3.0.4",
+ "from": "minimatch@>=3.0.4 <4.0.0",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"
+ },
+ "ms": {
+ "version": "2.0.0",
+ "from": "ms@2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
+ },
+ "pify": {
+ "version": "3.0.0",
+ "from": "pify@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz"
+ },
+ "postcss": {
+ "version": "6.0.8",
+ "from": "postcss@>=6.0.7 <7.0.0",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.8.tgz"
+ },
+ "postcss-less": {
+ "version": "1.1.0",
+ "from": "postcss-less@>=1.1.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-1.1.0.tgz",
+ "dependencies": {
+ "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"
+ },
+ "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"
+ }
+ }
+ },
+ "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"
+ },
+ "postcss": {
+ "version": "5.2.17",
+ "from": "postcss@>=5.2.16 <6.0.0",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.17.tgz"
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "from": "supports-color@>=3.2.3 <4.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz"
+ }
+ }
+ },
+ "postcss-media-query-parser": {
+ "version": "0.2.3",
+ "from": "postcss-media-query-parser@>=0.2.3 <0.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz"
+ },
+ "postcss-reporter": {
+ "version": "4.0.0",
+ "from": "postcss-reporter@>=4.0.0 <5.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-4.0.0.tgz",
+ "dependencies": {
+ "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"
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "from": "chalk@>=1.0.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz"
+ },
+ "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"
+ }
+ }
+ },
+ "postcss-scss": {
+ "version": "1.0.2",
+ "from": "postcss-scss@>=1.0.2 <2.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-1.0.2.tgz"
+ },
+ "postcss-selector-parser": {
+ "version": "2.2.3",
+ "from": "postcss-selector-parser@>=2.2.3 <3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz"
+ },
+ "resolve-from": {
+ "version": "3.0.0",
+ "from": "resolve-from@>=3.0.0 <4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz"
+ },
+ "specificity": {
+ "version": "0.3.1",
+ "from": "specificity@>=0.3.1 <0.4.0",
+ "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.3.1.tgz"
+ },
+ "string-width": {
+ "version": "2.1.1",
+ "from": "string-width@>=2.1.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+ "dependencies": {
+ "strip-ansi": {
+ "version": "4.0.0",
+ "from": "strip-ansi@>=4.0.0 <5.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz"
+ }
+ }
+ },
+ "stylelint": {
+ "version": "8.0.0",
+ "from": "stylelint@>=8.0.0 <9.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-8.0.0.tgz"
+ },
+ "sugarss": {
+ "version": "1.0.0",
+ "from": "sugarss@>=1.0.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-1.0.0.tgz"
+ },
+ "supports-color": {
+ "version": "4.2.1",
+ "from": "supports-color@>=4.2.0 <5.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.2.1.tgz"
+ },
+ "table": {
+ "version": "4.0.1",
+ "from": "table@>=4.0.1 <5.0.0",
+ "resolved": "https://registry.npmjs.org/table/-/table-4.0.1.tgz",
+ "dependencies": {
+ "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"
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "from": "chalk@>=1.1.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz"
+ },
+ "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"
+ }
+ }
+ }
+ }
+ },
+ "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.3",
+ "from": "through2@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.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"
+ },
+ "tiny-emitter": {
+ "version": "2.0.0",
+ "from": "tiny-emitter@>=2.0.0 <3.0.0",
+ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.0.tgz"
+ },
+ "to-camel-case": {
+ "version": "1.0.0",
+ "from": "to-camel-case@1.0.0",
+ "resolved": "https://registry.npmjs.org/to-camel-case/-/to-camel-case-1.0.0.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"
+ },
+ "to-no-case": {
+ "version": "1.0.2",
+ "from": "to-no-case@>=1.0.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz"
+ },
+ "to-space-case": {
+ "version": "1.0.0",
+ "from": "to-space-case@>=1.0.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.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-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"
+ },
+ "value-equal": {
+ "version": "0.2.1",
+ "from": "value-equal@>=0.2.0 <0.3.0",
+ "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.2.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.1",
+ "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"
+ },
+ "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 12bc565c3..a81722ce8 100644
--- a/package.json
+++ b/package.json
@@ -1,46 +1,103 @@
{
- "name": "Lidarr",
+ "name": "lidarr",
"version": "1.0.0",
"description": "Lidarr",
- "main": "main.js",
"scripts": {
"build": "gulp build",
"start": "gulp watch"
},
"repository": {
"type": "git",
- "url": "git://github.com/lidarr/Lidarr.git"
+ "url": "git://github.com/Lidarr/Lidarr.git"
},
- "author": "",
+ "author": "Team Lidarr",
"license": "GPL-3.0",
- "gitHead": "9ff7aa1bf7fe38c4c5bdb92f56c8ad556916ed67",
"readmeFilename": "readme.md",
"dependencies": {
- "autoprefixer-core": "5.2.1",
- "del": "1.2.0",
- "gulp": "3.9.0",
+ "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",
+ "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",
+ "clipboard": "1.7.1",
+ "css-loader": "0.23.1",
+ "del": "2.2.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",
+ "gulp": "3.9.1",
"gulp-cached": "1.1.0",
+ "gulp-clean-css": "3.3.1",
"gulp-concat": "2.6.0",
"gulp-declare": "0.3.0",
- "gulp-handlebars": "3.0.1",
- "gulp-jshint": "1.11.2",
- "gulp-less": "3.0.3",
- "gulp-livereload": "3.8.0",
- "gulp-postcss": "6.0.0",
- "gulp-print": "1.1.0",
- "gulp-replace": "0.5.3",
- "gulp-run": "1.6.8",
- "gulp-sourcemaps": "1.5.2",
+ "gulp-livereload": "3.8.1",
+ "gulp-postcss": "6.1.1",
+ "gulp-print": "2.0.1",
+ "gulp-sourcemaps": "1.6.0",
"gulp-stripbom": "1.0.4",
- "gulp-webpack": "1.5.0",
- "gulp-wrap": "0.11.0",
- "handlebars": "3.0.3",
- "jshint-loader": "0.8.3",
- "jshint-stylish": "2.0.1",
- "run-sequence": "1.1.1",
- "streamqueue": "1.1.0",
- "tar.gz": "0.1.1",
- "webpack": "1.12.0",
- "webpack-stream": "2.1.0"
+ "gulp-util": "3.0.7",
+ "gulp-watch": "4.3.5",
+ "gulp-wrap": "0.13.0",
+ "history": "4.6.3",
+ "jdu": "1.0.0",
+ "lodash": "4.17.4",
+ "mobile-detect": "1.3.6",
+ "moment": "2.17.1",
+ "mousetrap": "1.6.0",
+ "normalize.css": "5.0.0",
+ "postcss-loader": "0.9.1",
+ "postcss-nested": "1.0.0",
+ "postcss-simple-vars": "3.0.0",
+ "prop-types": "15.5.10",
+ "query-string": "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz",
+ "react": "15.6.1",
+ "react-addons-shallow-compare": "15.6.0",
+ "react-async-script": "0.9.1",
+ "react-autosuggest": "9.3.0",
+ "react-custom-scrollbars": "4.1.2",
+ "react-dnd": "2.4.0",
+ "react-dnd-html5-backend": "2.4.1",
+ "react-document-title": "2.0.3",
+ "react-dom": "15.6.1",
+ "react-google-recaptcha": "0.9.6",
+ "react-lazyload": "2.2.7",
+ "react-measure": "1.4.7",
+ "react-portal": "3.1.0",
+ "react-redux": "5.0.5",
+ "react-router-dom": "4.1.1",
+ "react-router-redux": "5.0.0-alpha.6",
+ "react-slider": "0.8.0",
+ "react-tabs": "1.1.0",
+ "react-tag-autocomplete": "5.4.0",
+ "react-tether": "0.5.7",
+ "react-virtualized": "9.8.0",
+ "redux": "3.7.0",
+ "redux-actions": "2.0.3",
+ "redux-batched-actions": "0.2.0",
+ "redux-localstorage": "0.4.1",
+ "redux-raven-middleware": "1.2.0",
+ "redux-thunk": "2.2.0",
+ "require-nocache": "1.0.0",
+ "reselect": "3.0.1",
+ "run-sequence": "1.2.0",
+ "streamqueue": "1.1.1",
+ "style-loader": "0.13.1",
+ "stylelint": "7.3.1",
+ "stylelint-order": "0.6.0",
+ "tar.gz": "1.0.3",
+ "url-loader": "0.5.7",
+ "webpack": "1.13.1",
+ "webpack-stream": "2.1.1"
}
}
diff --git a/src/Lidarr.sln b/src/Lidarr.sln
new file mode 100644
index 000000000..037bd252c
--- /dev/null
+++ b/src/Lidarr.sln
@@ -0,0 +1,339 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 15
+VisualStudioVersion = 15.0.26730.10
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{57A04B72-8088-4F75-A582-1158CF8291F7}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test.Common", "Test.Common", "{47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Test.Dummy", "NzbDrone.Test.Dummy\NzbDrone.Test.Dummy.csproj", "{FAFB5948-A222-4CF6-AD14-026BE7564802}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Test.Common", "NzbDrone.Test.Common\NzbDrone.Test.Common.csproj", "{CADDFCE0-7509-4430-8364-2074E1EEFCA2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Core.Test", "NzbDrone.Core.Test\NzbDrone.Core.Test.csproj", "{193ADD3B-792B-4173-8E4C-5A3F8F0237F0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Host.Test", "NzbDrone.App.Test\NzbDrone.Host.Test.csproj", "{C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Update.Test", "NzbDrone.Update.Test\NzbDrone.Update.Test.csproj", "{35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Common.Test", "NzbDrone.Common.Test\NzbDrone.Common.Test.csproj", "{BEC74619-DDBB-4FBA-B517-D3E20AFC9997}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Api.Test", "NzbDrone.Api.Test\NzbDrone.Api.Test.csproj", "{D18A5DEB-5102-4775-A1AF-B75DAAA8907B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Libraries.Test", "NzbDrone.Libraries.Test\NzbDrone.Libraries.Test.csproj", "{CBF6B8B0-A015-413A-8C86-01238BB45770}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Integration.Test", "NzbDrone.Integration.Test\NzbDrone.Integration.Test.csproj", "{8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Automation.Test", "NzbDrone.Automation.Test\NzbDrone.Automation.Test.csproj", "{CC26800D-F67E-464B-88DE-8EB1A0C227A3}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WindowsServiceHelpers", "WindowsServiceHelpers", "{F9E67978-5CD6-4A5F-827B-4249711C0B02}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceInstall", "ServiceHelpers\ServiceInstall\ServiceInstall.csproj", "{6BCE712F-846D-4846-9D1B-A66B858DA755}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceUninstall", "ServiceHelpers\ServiceUninstall\ServiceUninstall.csproj", "{700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Core", "NzbDrone.Core\NzbDrone.Core.csproj", "{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Update", "NzbDrone.Update\NzbDrone.Update.csproj", "{4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Common", "NzbDrone.Common\NzbDrone.Common.csproj", "{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}"
+ ProjectSection(ProjectDependencies) = postProject
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} = {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{1E6B3CBE-1578-41C1-9BF9-78D818740BE9}"
+ ProjectSection(SolutionItems) = preProject
+ .nuget\NuGet.exe = .nuget\NuGet.exe
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Api", "NzbDrone.Api\NzbDrone.Api.csproj", "{FD286DF8-2D3A-4394-8AD5-443FADE55FB2}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Host", "Host", "{486ADF86-DD89-4E19-B805-9D94F19800D9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Console", "NzbDrone.Console\NzbDrone.Console.csproj", "{3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Host", "NzbDrone.Host\NzbDrone.Host.csproj", "{95C11A9E-56ED-456A-8447-2C89C1139266}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone", "NzbDrone\NzbDrone.csproj", "{D12F7F2F-8A3C-415F-88FA-6DD061A84869}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.SignalR", "NzbDrone.SignalR\NzbDrone.SignalR.csproj", "{7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "External", "External", "{F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNet.SignalR.Core", "Microsoft.AspNet.SignalR.Core\Microsoft.AspNet.SignalR.Core.csproj", "{1B9A82C4-BCA1-4834-A33E-226F17BE070B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNet.SignalR.Owin", "Microsoft.AspNet.SignalR.Owin\Microsoft.AspNet.SignalR.Owin.csproj", "{2B8C6DAD-4D85-41B1-83FD-248D9F347522}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Marr.Data", "Marr.Data\Marr.Data.csproj", "{F6FC6BE7-0847-4817-A1ED-223DC647C3D7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Mono", "NzbDrone.Mono\NzbDrone.Mono.csproj", "{15AD7579-A314-4626-B556-663F51D97CD1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Windows", "NzbDrone.Windows\NzbDrone.Windows.csproj", "{911284D3-F130-459E-836C-2430B6FBF21D}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Platform", "Platform", "{0F0D4998-8F5D-4467-A909-BB192C4B3B4B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Platform", "Platform", "{4EACDBBC-BCD7-4765-A57B-3E08331E4749}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Windows.Test", "NzbDrone.Windows.Test\NzbDrone.Windows.Test.csproj", "{80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Mono.Test", "NzbDrone.Mono.Test\NzbDrone.Mono.Test.csproj", "{40D72824-7D02-4A77-9106-8FE0EEA2B997}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoTorrent", "MonoTorrent\MonoTorrent.csproj", "{411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogentriesCore", "LogentriesCore\LogentriesCore.csproj", "{90D6E9FC-7B88-4E1B-B018-8FA742274558}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogentriesNLog", "LogentriesNLog\LogentriesNLog.csproj", "{9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}"
+ ProjectSection(ProjectDependencies) = postProject
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558} = {90D6E9FC-7B88-4E1B-B018-8FA742274558}
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CurlSharp", "ExternalModules\CurlSharp\CurlSharp\CurlSharp.csproj", "{74420A79-CC16-442C-8B1E-7C1B913844F0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lidarr.Api.V3", "Sonarr.Api.V3\Lidarr.Api.V3.csproj", "{7140FF1F-79BE-492F-9188-B21A050BF708}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lidarr.Http", "Sonarr.Http\Lidarr.Http.csproj", "{5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|x86 = Debug|x86
+ Mono|x86 = Mono|x86
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {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
+ {FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.Build.0 = Release|x86
+ {FAFB5948-A222-4CF6-AD14-026BE7564802}.Release|x86.ActiveCfg = Release|x86
+ {FAFB5948-A222-4CF6-AD14-026BE7564802}.Release|x86.Build.0 = Release|x86
+ {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Debug|x86.ActiveCfg = Debug|x86
+ {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Debug|x86.Build.0 = Debug|x86
+ {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Mono|x86.ActiveCfg = Debug|x86
+ {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Mono|x86.Build.0 = Debug|x86
+ {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Release|x86.ActiveCfg = Release|x86
+ {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Release|x86.Build.0 = Release|x86
+ {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Debug|x86.ActiveCfg = Debug|x86
+ {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Debug|x86.Build.0 = Debug|x86
+ {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Mono|x86.ActiveCfg = Debug|x86
+ {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Mono|x86.Build.0 = Debug|x86
+ {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Release|x86.ActiveCfg = Release|x86
+ {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Release|x86.Build.0 = Release|x86
+ {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Debug|x86.ActiveCfg = Debug|x86
+ {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Debug|x86.Build.0 = Debug|x86
+ {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Mono|x86.ActiveCfg = Debug|x86
+ {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Mono|x86.Build.0 = Debug|x86
+ {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Release|x86.ActiveCfg = Release|x86
+ {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Release|x86.Build.0 = Release|x86
+ {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Debug|x86.ActiveCfg = Debug|x86
+ {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Debug|x86.Build.0 = Debug|x86
+ {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Mono|x86.ActiveCfg = Debug|x86
+ {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Mono|x86.Build.0 = Debug|x86
+ {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Release|x86.ActiveCfg = Release|x86
+ {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Release|x86.Build.0 = Release|x86
+ {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Debug|x86.ActiveCfg = Debug|x86
+ {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Debug|x86.Build.0 = Debug|x86
+ {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Mono|x86.ActiveCfg = Debug|x86
+ {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Mono|x86.Build.0 = Debug|x86
+ {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Release|x86.ActiveCfg = Release|x86
+ {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Release|x86.Build.0 = Release|x86
+ {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Debug|x86.ActiveCfg = Debug|x86
+ {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Debug|x86.Build.0 = Debug|x86
+ {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Mono|x86.ActiveCfg = Release|x86
+ {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Mono|x86.Build.0 = Release|x86
+ {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Release|x86.ActiveCfg = Release|x86
+ {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Release|x86.Build.0 = Release|x86
+ {CBF6B8B0-A015-413A-8C86-01238BB45770}.Debug|x86.ActiveCfg = Debug|x86
+ {CBF6B8B0-A015-413A-8C86-01238BB45770}.Debug|x86.Build.0 = Debug|x86
+ {CBF6B8B0-A015-413A-8C86-01238BB45770}.Mono|x86.ActiveCfg = Debug|x86
+ {CBF6B8B0-A015-413A-8C86-01238BB45770}.Mono|x86.Build.0 = Debug|x86
+ {CBF6B8B0-A015-413A-8C86-01238BB45770}.Release|x86.ActiveCfg = Release|x86
+ {CBF6B8B0-A015-413A-8C86-01238BB45770}.Release|x86.Build.0 = Release|x86
+ {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Debug|x86.ActiveCfg = Debug|x86
+ {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Debug|x86.Build.0 = Debug|x86
+ {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Mono|x86.ActiveCfg = Debug|x86
+ {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Mono|x86.Build.0 = Debug|x86
+ {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Release|x86.ActiveCfg = Release|x86
+ {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Release|x86.Build.0 = Release|x86
+ {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Debug|x86.ActiveCfg = Debug|x86
+ {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Debug|x86.Build.0 = Debug|x86
+ {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Mono|x86.ActiveCfg = Debug|x86
+ {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Mono|x86.Build.0 = Debug|x86
+ {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Release|x86.ActiveCfg = Release|x86
+ {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Release|x86.Build.0 = Release|x86
+ {6BCE712F-846D-4846-9D1B-A66B858DA755}.Debug|x86.ActiveCfg = Debug|x86
+ {6BCE712F-846D-4846-9D1B-A66B858DA755}.Debug|x86.Build.0 = Debug|x86
+ {6BCE712F-846D-4846-9D1B-A66B858DA755}.Mono|x86.ActiveCfg = Debug|x86
+ {6BCE712F-846D-4846-9D1B-A66B858DA755}.Release|x86.ActiveCfg = Release|x86
+ {6BCE712F-846D-4846-9D1B-A66B858DA755}.Release|x86.Build.0 = Release|x86
+ {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Debug|x86.ActiveCfg = Debug|x86
+ {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Debug|x86.Build.0 = Debug|x86
+ {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Mono|x86.ActiveCfg = Debug|x86
+ {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Release|x86.ActiveCfg = Release|x86
+ {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Release|x86.Build.0 = Release|x86
+ {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Debug|x86.ActiveCfg = Debug|x86
+ {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Debug|x86.Build.0 = Debug|x86
+ {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Mono|x86.ActiveCfg = Release|x86
+ {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Mono|x86.Build.0 = Release|x86
+ {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Release|x86.ActiveCfg = Release|x86
+ {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Release|x86.Build.0 = Release|x86
+ {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Debug|x86.ActiveCfg = Debug|x86
+ {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Debug|x86.Build.0 = Debug|x86
+ {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Mono|x86.ActiveCfg = Debug|x86
+ {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Mono|x86.Build.0 = Debug|x86
+ {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Release|x86.ActiveCfg = Release|x86
+ {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Release|x86.Build.0 = Release|x86
+ {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Debug|x86.ActiveCfg = Debug|x86
+ {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Debug|x86.Build.0 = Debug|x86
+ {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Mono|x86.ActiveCfg = Release|x86
+ {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Mono|x86.Build.0 = Release|x86
+ {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Release|x86.ActiveCfg = Release|x86
+ {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Release|x86.Build.0 = Release|x86
+ {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Debug|x86.ActiveCfg = Debug|x86
+ {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Debug|x86.Build.0 = Debug|x86
+ {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Mono|x86.ActiveCfg = Release|x86
+ {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
+ {95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.Build.0 = Debug|x86
+ {95C11A9E-56ED-456A-8447-2C89C1139266}.Release|x86.ActiveCfg = Release|x86
+ {95C11A9E-56ED-456A-8447-2C89C1139266}.Release|x86.Build.0 = Release|x86
+ {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Debug|x86.ActiveCfg = Debug|x86
+ {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Debug|x86.Build.0 = Debug|x86
+ {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Mono|x86.ActiveCfg = Release|x86
+ {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Release|x86.ActiveCfg = Release|x86
+ {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Release|x86.Build.0 = Release|x86
+ {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Debug|x86.ActiveCfg = Debug|x86
+ {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Debug|x86.Build.0 = Debug|x86
+ {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Mono|x86.ActiveCfg = Debug|x86
+ {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Mono|x86.Build.0 = Debug|x86
+ {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Release|x86.ActiveCfg = Release|x86
+ {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Release|x86.Build.0 = Release|x86
+ {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Debug|x86.ActiveCfg = Debug|x86
+ {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Debug|x86.Build.0 = Debug|x86
+ {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Mono|x86.ActiveCfg = Debug|x86
+ {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Mono|x86.Build.0 = Debug|x86
+ {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Release|x86.ActiveCfg = Release|x86
+ {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Release|x86.Build.0 = Release|x86
+ {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Debug|x86.ActiveCfg = Debug|x86
+ {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Debug|x86.Build.0 = Debug|x86
+ {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Mono|x86.ActiveCfg = Release|x86
+ {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Mono|x86.Build.0 = Release|x86
+ {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Release|x86.ActiveCfg = Release|x86
+ {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Release|x86.Build.0 = Release|x86
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Debug|x86.ActiveCfg = Debug|x86
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Debug|x86.Build.0 = Debug|x86
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Mono|x86.ActiveCfg = Release|x86
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Mono|x86.Build.0 = Release|x86
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Release|x86.ActiveCfg = Release|x86
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Release|x86.Build.0 = Release|x86
+ {15AD7579-A314-4626-B556-663F51D97CD1}.Debug|x86.ActiveCfg = Debug|x86
+ {15AD7579-A314-4626-B556-663F51D97CD1}.Debug|x86.Build.0 = Debug|x86
+ {15AD7579-A314-4626-B556-663F51D97CD1}.Mono|x86.ActiveCfg = Release|x86
+ {15AD7579-A314-4626-B556-663F51D97CD1}.Release|x86.ActiveCfg = Release|x86
+ {15AD7579-A314-4626-B556-663F51D97CD1}.Release|x86.Build.0 = Release|x86
+ {911284D3-F130-459E-836C-2430B6FBF21D}.Debug|x86.ActiveCfg = Debug|x86
+ {911284D3-F130-459E-836C-2430B6FBF21D}.Debug|x86.Build.0 = Debug|x86
+ {911284D3-F130-459E-836C-2430B6FBF21D}.Mono|x86.ActiveCfg = Release|x86
+ {911284D3-F130-459E-836C-2430B6FBF21D}.Release|x86.ActiveCfg = Release|x86
+ {911284D3-F130-459E-836C-2430B6FBF21D}.Release|x86.Build.0 = Release|x86
+ {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.ActiveCfg = Debug|x86
+ {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.Build.0 = Debug|x86
+ {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Mono|x86.ActiveCfg = Release|x86
+ {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Release|x86.ActiveCfg = Release|x86
+ {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Release|x86.Build.0 = Release|x86
+ {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.ActiveCfg = Debug|x86
+ {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.Build.0 = Debug|x86
+ {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Mono|x86.ActiveCfg = Release|x86
+ {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.ActiveCfg = Release|x86
+ {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.Build.0 = Release|x86
+ {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.ActiveCfg = Debug|x86
+ {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.Build.0 = Debug|x86
+ {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.ActiveCfg = Release|x86
+ {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.Build.0 = Release|x86
+ {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.ActiveCfg = Release|x86
+ {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.Build.0 = Release|x86
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Debug|x86.ActiveCfg = Debug|x86
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Debug|x86.Build.0 = Debug|x86
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Mono|x86.ActiveCfg = Release|x86
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Mono|x86.Build.0 = Release|x86
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Release|x86.ActiveCfg = Release|x86
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Release|x86.Build.0 = Release|x86
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Debug|x86.ActiveCfg = Debug|x86
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Debug|x86.Build.0 = Debug|x86
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Mono|x86.ActiveCfg = Release|x86
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Mono|x86.Build.0 = Release|x86
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Release|x86.ActiveCfg = Release|x86
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Release|x86.Build.0 = Release|x86
+ {74420A79-CC16-442C-8B1E-7C1B913844F0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {74420A79-CC16-442C-8B1E-7C1B913844F0}.Debug|x86.Build.0 = Debug|Any CPU
+ {74420A79-CC16-442C-8B1E-7C1B913844F0}.Mono|x86.ActiveCfg = Release|Any CPU
+ {74420A79-CC16-442C-8B1E-7C1B913844F0}.Mono|x86.Build.0 = Release|Any CPU
+ {74420A79-CC16-442C-8B1E-7C1B913844F0}.Release|x86.ActiveCfg = Release|Any CPU
+ {74420A79-CC16-442C-8B1E-7C1B913844F0}.Release|x86.Build.0 = Release|Any CPU
+ {7140FF1F-79BE-492F-9188-B21A050BF708}.Debug|x86.ActiveCfg = Debug|x86
+ {7140FF1F-79BE-492F-9188-B21A050BF708}.Debug|x86.Build.0 = Debug|x86
+ {7140FF1F-79BE-492F-9188-B21A050BF708}.Mono|x86.ActiveCfg = Release|x86
+ {7140FF1F-79BE-492F-9188-B21A050BF708}.Mono|x86.Build.0 = Release|x86
+ {7140FF1F-79BE-492F-9188-B21A050BF708}.Release|x86.ActiveCfg = Release|x86
+ {7140FF1F-79BE-492F-9188-B21A050BF708}.Release|x86.Build.0 = Release|x86
+ {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Debug|x86.ActiveCfg = Debug|x86
+ {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Debug|x86.Build.0 = Debug|x86
+ {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Mono|x86.ActiveCfg = Release|x86
+ {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Mono|x86.Build.0 = Release|x86
+ {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Release|x86.ActiveCfg = Release|x86
+ {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Release|x86.Build.0 = Release|x86
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {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}
+ {193ADD3B-792B-4173-8E4C-5A3F8F0237F0} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {BEC74619-DDBB-4FBA-B517-D3E20AFC9997} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {D18A5DEB-5102-4775-A1AF-B75DAAA8907B} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {CBF6B8B0-A015-413A-8C86-01238BB45770} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {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}
+ {1B9A82C4-BCA1-4834-A33E-226F17BE070B} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
+ {2B8C6DAD-4D85-41B1-83FD-248D9F347522} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
+ {15AD7579-A314-4626-B556-663F51D97CD1} = {0F0D4998-8F5D-4467-A909-BB192C4B3B4B}
+ {911284D3-F130-459E-836C-2430B6FBF21D} = {0F0D4998-8F5D-4467-A909-BB192C4B3B4B}
+ {4EACDBBC-BCD7-4765-A57B-3E08331E4749} = {57A04B72-8088-4F75-A582-1158CF8291F7}
+ {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749}
+ {40D72824-7D02-4A77-9106-8FE0EEA2B997} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749}
+ {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
+ {90D6E9FC-7B88-4E1B-B018-8FA742274558} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
+ {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
+ {74420A79-CC16-442C-8B1E-7C1B913844F0} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.0\lib\NET35;packages\Unity.2.1.505.2\lib\NET35
+ SolutionGuid = {2C047BC5-490F-4DCE-962F-141370D23765}
+ EndGlobalSection
+ GlobalSection(MonoDevelopProperties) = preSolution
+ StartupItem = NzbDrone.Console\NzbDrone.Console.csproj
+ EndGlobalSection
+ GlobalSection(JSLint) = preSolution
+ SolutionConfigurationLocation = JSLintOptions.xml
+ EndGlobalSection
+EndGlobal
diff --git a/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs b/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs
index 385a9b989..e78709c2f 100644
--- a/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs
+++ b/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs
@@ -1,6 +1,6 @@
-using FluentAssertions;
+using FluentAssertions;
using NUnit.Framework;
-using NzbDrone.Api.ClientSchema;
+using Lidarr.Http.ClientSchema;
using NzbDrone.Core.Annotations;
using NzbDrone.Test.Common;
@@ -45,4 +45,4 @@ namespace NzbDrone.Api.Test.ClientSchemaTests
public string Other { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj b/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj
index 69402ccc5..3e32acc6d 100644
--- a/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj
+++ b/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj
@@ -90,6 +90,10 @@
{CADDFCE0-7509-4430-8364-2074E1EEFCA2}
NzbDrone.Test.Common
+
+ {5370bff7-1bd7-46bc-af06-7d9ea5cda1d6}
+ Lidarr.Http
+
diff --git a/src/NzbDrone.Api/AlbumStudio/AlbumStudioModule.cs b/src/NzbDrone.Api/AlbumStudio/AlbumStudioModule.cs
index 4fa964a2f..2409d932a 100644
--- a/src/NzbDrone.Api/AlbumStudio/AlbumStudioModule.cs
+++ b/src/NzbDrone.Api/AlbumStudio/AlbumStudioModule.cs
@@ -1,5 +1,5 @@
-using Nancy;
-using NzbDrone.Api.Extensions;
+using Nancy;
+using Lidarr.Http.Extensions;
using NzbDrone.Core.Music;
namespace NzbDrone.Api.AlbumPass
diff --git a/src/NzbDrone.Api/Albums/AlbumModule.cs b/src/NzbDrone.Api/Albums/AlbumModule.cs
index e2d635fd0..bf36929a6 100644
--- a/src/NzbDrone.Api/Albums/AlbumModule.cs
+++ b/src/NzbDrone.Api/Albums/AlbumModule.cs
@@ -1,5 +1,5 @@
-using System.Collections.Generic;
-using NzbDrone.Api.REST;
+using System.Collections.Generic;
+using Lidarr.Http.REST;
using NzbDrone.Core.Music;
using NzbDrone.Core.ArtistStats;
using NzbDrone.Core.DecisionEngine;
@@ -12,7 +12,7 @@ namespace NzbDrone.Api.Albums
public AlbumModule(IArtistService artistService,
IArtistStatisticsService artistStatisticsService,
IAlbumService albumService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(albumService, artistStatisticsService, artistService, qualityUpgradableSpecification, signalRBroadcaster)
{
diff --git a/src/NzbDrone.Api/Albums/AlbumModuleWithSignalR.cs b/src/NzbDrone.Api/Albums/AlbumModuleWithSignalR.cs
index 59fe8fc2d..f799c02ac 100644
--- a/src/NzbDrone.Api/Albums/AlbumModuleWithSignalR.cs
+++ b/src/NzbDrone.Api/Albums/AlbumModuleWithSignalR.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
@@ -13,20 +13,21 @@ using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music;
using NzbDrone.Core.ArtistStats;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.Albums
{
- public abstract class AlbumModuleWithSignalR : NzbDroneRestModuleWithSignalR
+ public abstract class AlbumModuleWithSignalR : LidarrRestModuleWithSignalR
{
protected readonly IAlbumService _albumService;
protected readonly IArtistStatisticsService _artistStatisticsService;
protected readonly IArtistService _artistService;
- protected readonly IQualityUpgradableSpecification _qualityUpgradableSpecification;
+ protected readonly IUpgradableSpecification _qualityUpgradableSpecification;
protected AlbumModuleWithSignalR(IAlbumService albumService,
IArtistStatisticsService artistStatisticsService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(signalRBroadcaster)
{
@@ -41,7 +42,7 @@ namespace NzbDrone.Api.Albums
protected AlbumModuleWithSignalR(IAlbumService albumService,
IArtistStatisticsService artistStatisticsService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster,
string resource)
: base(signalRBroadcaster, resource)
diff --git a/src/NzbDrone.Api/Albums/AlbumResource.cs b/src/NzbDrone.Api/Albums/AlbumResource.cs
index a283deebf..e66d67c64 100644
--- a/src/NzbDrone.Api/Albums/AlbumResource.cs
+++ b/src/NzbDrone.Api/Albums/AlbumResource.cs
@@ -1,9 +1,9 @@
-using NzbDrone.Core.Music;
+using NzbDrone.Core.Music;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Api.Music;
using NzbDrone.Core.MediaCover;
diff --git a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs b/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs
deleted file mode 100644
index 580196363..000000000
--- a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-using System;
-using System.Text;
-using Nancy;
-using Nancy.Authentication.Basic;
-using Nancy.Authentication.Forms;
-using Nancy.Bootstrapper;
-using Nancy.Cryptography;
-using NzbDrone.Api.Extensions;
-using NzbDrone.Api.Extensions.Pipelines;
-using NzbDrone.Core.Authentication;
-using NzbDrone.Core.Configuration;
-
-namespace NzbDrone.Api.Authentication
-{
- public class EnableAuthInNancy : IRegisterNancyPipeline
- {
- private readonly IAuthenticationService _authenticationService;
- private readonly IConfigService _configService;
- private readonly IConfigFileProvider _configFileProvider;
-
- public EnableAuthInNancy(IAuthenticationService authenticationService,
- IConfigService configService,
- IConfigFileProvider configFileProvider)
- {
- _authenticationService = authenticationService;
- _configService = configService;
- _configFileProvider = configFileProvider;
- }
-
- public int Order => 10;
-
- public void Register(IPipelines pipelines)
- {
- if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms)
- {
- RegisterFormsAuth(pipelines);
- }
-
- else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic)
- {
- pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Lidarr"));
- }
-
- pipelines.BeforeRequest.AddItemToEndOfPipeline((Func) RequiresAuthentication);
- pipelines.AfterRequest.AddItemToEndOfPipeline((Action) RemoveLoginHooksForApiCalls);
- }
-
- private Response RequiresAuthentication(NancyContext context)
- {
- Response response = null;
-
- if (!_authenticationService.IsAuthenticated(context))
- {
- response = new Response { StatusCode = HttpStatusCode.Unauthorized };
- }
-
- return response;
- }
-
- private void RegisterFormsAuth(IPipelines pipelines)
- {
- var cryptographyConfiguration = new CryptographyConfiguration(
- new RijndaelEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase, Encoding.ASCII.GetBytes(_configService.RijndaelSalt))),
- new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt)))
- );
-
- FormsAuthentication.Enable(pipelines, new FormsAuthenticationConfiguration
- {
- RedirectUrl = _configFileProvider.UrlBase + "/login",
- UserMapper = _authenticationService,
- CryptographyConfiguration = cryptographyConfiguration
- });
- }
-
- private void RemoveLoginHooksForApiCalls(NancyContext context)
- {
- if (context.Request.IsApiRequest())
- {
- if ((context.Response.StatusCode == HttpStatusCode.SeeOther &&
- context.Response.Headers["Location"].StartsWith($"{_configFileProvider.UrlBase}/login", StringComparison.InvariantCultureIgnoreCase)) ||
- context.Response.StatusCode == HttpStatusCode.Unauthorized)
- {
- context.Response = new { Error = "Unauthorized" }.AsResponse(HttpStatusCode.Unauthorized);
- }
- }
- }
- }
-}
diff --git a/src/NzbDrone.Api/Blacklist/BlacklistModule.cs b/src/NzbDrone.Api/Blacklist/BlacklistModule.cs
index 1687b31e3..6ee093e49 100644
--- a/src/NzbDrone.Api/Blacklist/BlacklistModule.cs
+++ b/src/NzbDrone.Api/Blacklist/BlacklistModule.cs
@@ -1,9 +1,10 @@
-using NzbDrone.Core.Blacklisting;
+using NzbDrone.Core.Blacklisting;
using NzbDrone.Core.Datastore;
+using Lidarr.Http;
namespace NzbDrone.Api.Blacklist
{
- public class BlacklistModule : NzbDroneRestModule
+ public class BlacklistModule : LidarrRestModule
{
private readonly IBlacklistService _blacklistService;
diff --git a/src/NzbDrone.Api/Blacklist/BlacklistResource.cs b/src/NzbDrone.Api/Blacklist/BlacklistResource.cs
index d534e720f..563f9d95a 100644
--- a/src/NzbDrone.Api/Blacklist/BlacklistResource.cs
+++ b/src/NzbDrone.Api/Blacklist/BlacklistResource.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.Collections.Generic;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Qualities;
using NzbDrone.Api.Music;
using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Languages;
namespace NzbDrone.Api.Blacklist
{
@@ -17,6 +18,7 @@ namespace NzbDrone.Api.Blacklist
public DownloadProtocol Protocol { get; set; }
public string Indexer { get; set; }
public string Message { get; set; }
+ public Language Language { get; set; }
public ArtistResource Artist { get; set; }
}
diff --git a/src/NzbDrone.Api/Calendar/CalendarModule.cs b/src/NzbDrone.Api/Calendar/CalendarModule.cs
index 352fad3a6..5a857c699 100644
--- a/src/NzbDrone.Api/Calendar/CalendarModule.cs
+++ b/src/NzbDrone.Api/Calendar/CalendarModule.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Api.Episodes;
@@ -16,9 +16,9 @@ namespace NzbDrone.Api.Calendar
public CalendarModule(IAlbumService albumService,
IArtistStatisticsService artistStatisticsService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification upgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
- : base(albumService, artistStatisticsService, artistService, qualityUpgradableSpecification, signalRBroadcaster, "calendar")
+ : base(albumService, artistStatisticsService, artistService, upgradableSpecification, signalRBroadcaster, "calendar")
{
GetResourceAll = GetCalendar;
}
diff --git a/src/NzbDrone.Api/ClientSchema/FieldDefinitionAttribute.cs b/src/NzbDrone.Api/ClientSchema/FieldDefinitionAttribute.cs
deleted file mode 100644
index 4e796bd8c..000000000
--- a/src/NzbDrone.Api/ClientSchema/FieldDefinitionAttribute.cs
+++ /dev/null
@@ -1,4 +0,0 @@
-namespace NzbDrone.Api.ClientSchema
-{
-
-}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/ClientSchema/SchemaDeserializer.cs b/src/NzbDrone.Api/ClientSchema/SchemaDeserializer.cs
deleted file mode 100644
index 6af07257f..000000000
--- a/src/NzbDrone.Api/ClientSchema/SchemaDeserializer.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace NzbDrone.Api.ClientSchema
-{
- public static class SchemaDeserializer
- {
-
- }
-}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Commands/CommandModule.cs b/src/NzbDrone.Api/Commands/CommandModule.cs
index fcaeef9c4..ecf2a6f5c 100644
--- a/src/NzbDrone.Api/Commands/CommandModule.cs
+++ b/src/NzbDrone.Api/Commands/CommandModule.cs
@@ -1,19 +1,21 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.Extensions;
-using NzbDrone.Api.Validation;
+using Lidarr.Http.Extensions;
using NzbDrone.Common;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ProgressMessaging;
using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
+using Lidarr.Http.Validation;
namespace NzbDrone.Api.Commands
{
- public class CommandModule : NzbDroneRestModuleWithSignalR, IHandle
+ public class CommandModule : LidarrRestModuleWithSignalR, IHandle
{
private readonly IManageCommandQueue _commandQueueManager;
private readonly IServiceFactory _serviceFactory;
@@ -63,4 +65,4 @@ namespace NzbDrone.Api.Commands
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Commands/CommandResource.cs b/src/NzbDrone.Api/Commands/CommandResource.cs
index cf09f12ac..5fd2db0ed 100644
--- a/src/NzbDrone.Api/Commands/CommandResource.cs
+++ b/src/NzbDrone.Api/Commands/CommandResource.cs
@@ -1,8 +1,8 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Api.Commands
diff --git a/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs b/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs
index b9930ad0d..3ad22e818 100644
--- a/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs
+++ b/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs
@@ -1,4 +1,4 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Config
diff --git a/src/NzbDrone.Api/Config/HostConfigModule.cs b/src/NzbDrone.Api/Config/HostConfigModule.cs
index 367bf770d..7bf5a7f5a 100644
--- a/src/NzbDrone.Api/Config/HostConfigModule.cs
+++ b/src/NzbDrone.Api/Config/HostConfigModule.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using System.Reflection;
using FluentValidation;
using NzbDrone.Common.EnvironmentInfo;
@@ -8,10 +8,11 @@ using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
+using Lidarr.Http;
namespace NzbDrone.Api.Config
{
- public class HostConfigModule : NzbDroneRestModule
+ public class HostConfigModule : LidarrRestModule
{
private readonly IConfigFileProvider _configFileProvider;
private readonly IConfigService _configService;
diff --git a/src/NzbDrone.Api/Config/HostConfigResource.cs b/src/NzbDrone.Api/Config/HostConfigResource.cs
index 930e0301c..312571f0f 100644
--- a/src/NzbDrone.Api/Config/HostConfigResource.cs
+++ b/src/NzbDrone.Api/Config/HostConfigResource.cs
@@ -1,4 +1,4 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update;
diff --git a/src/NzbDrone.Api/Config/IndexerConfigModule.cs b/src/NzbDrone.Api/Config/IndexerConfigModule.cs
index 73c2442b8..9a290e650 100644
--- a/src/NzbDrone.Api/Config/IndexerConfigModule.cs
+++ b/src/NzbDrone.Api/Config/IndexerConfigModule.cs
@@ -1,5 +1,5 @@
-using FluentValidation;
-using NzbDrone.Api.Validation;
+using FluentValidation;
+using Lidarr.Http.Validation;
using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Config
@@ -25,4 +25,4 @@ namespace NzbDrone.Api.Config
return IndexerConfigResourceMapper.ToResource(model);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Config/IndexerConfigResource.cs b/src/NzbDrone.Api/Config/IndexerConfigResource.cs
index 179e28c3f..4a14e3bdd 100644
--- a/src/NzbDrone.Api/Config/IndexerConfigResource.cs
+++ b/src/NzbDrone.Api/Config/IndexerConfigResource.cs
@@ -1,4 +1,4 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Config
diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
index b2a7c9b65..281533067 100644
--- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
+++ b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
@@ -1,4 +1,4 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles;
diff --git a/src/NzbDrone.Api/Config/NamingConfigModule.cs b/src/NzbDrone.Api/Config/NamingConfigModule.cs
index abe7096c9..395e7f15e 100644
--- a/src/NzbDrone.Api/Config/NamingConfigModule.cs
+++ b/src/NzbDrone.Api/Config/NamingConfigModule.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Results;
@@ -6,11 +6,13 @@ using Nancy.Responses;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Organizer;
using Nancy.ModelBinding;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Config
{
- public class NamingConfigModule : NzbDroneRestModule
+ public class NamingConfigModule : LidarrRestModule
{
private readonly INamingConfigService _namingConfigService;
private readonly IFilenameSampleService _filenameSampleService;
diff --git a/src/NzbDrone.Api/Config/NamingConfigResource.cs b/src/NzbDrone.Api/Config/NamingConfigResource.cs
index 47acd6a24..dd8572847 100644
--- a/src/NzbDrone.Api/Config/NamingConfigResource.cs
+++ b/src/NzbDrone.Api/Config/NamingConfigResource.cs
@@ -1,4 +1,4 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Organizer;
namespace NzbDrone.Api.Config
diff --git a/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs b/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs
index e5d324950..49a4af748 100644
--- a/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs
+++ b/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs
@@ -1,11 +1,12 @@
-using System.Linq;
+using System.Linq;
using System.Reflection;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Configuration;
+using Lidarr.Http;
namespace NzbDrone.Api.Config
{
- public abstract class NzbDroneConfigModule : NzbDroneRestModule where TResource : RestResource, new()
+ public abstract class NzbDroneConfigModule : LidarrRestModule where TResource : RestResource, new()
{
private readonly IConfigService _configService;
diff --git a/src/NzbDrone.Api/Config/UiConfigResource.cs b/src/NzbDrone.Api/Config/UiConfigResource.cs
index 7c7d27b67..44e6ff0b1 100644
--- a/src/NzbDrone.Api/Config/UiConfigResource.cs
+++ b/src/NzbDrone.Api/Config/UiConfigResource.cs
@@ -1,4 +1,4 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Config
diff --git a/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs b/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs
index f6d8354b4..c59ef45e0 100644
--- a/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs
+++ b/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs
@@ -1,9 +1,10 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Core.DiskSpace;
+using Lidarr.Http;
namespace NzbDrone.Api.DiskSpace
{
- public class DiskSpaceModule :NzbDroneRestModule
+ public class DiskSpaceModule : LidarrRestModule
{
private readonly IDiskSpaceService _diskSpaceService;
diff --git a/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs b/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs
index fc36f9d5c..71069da5b 100644
--- a/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs
+++ b/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs
@@ -1,4 +1,4 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
namespace NzbDrone.Api.DiskSpace
{
diff --git a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs
index d89a0068c..f4536c79a 100644
--- a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs
+++ b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs
@@ -1,7 +1,7 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.IO;
using NLog;
-using NzbDrone.Api.REST;
+using Lidarr.Http;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Events;
@@ -15,14 +15,14 @@ using System;
namespace NzbDrone.Api.EpisodeFiles
{
- public class EpisodeFileModule : NzbDroneRestModuleWithSignalR,
+ public class EpisodeFileModule : LidarrRestModuleWithSignalR,
IHandle
{
private readonly IMediaFileService _mediaFileService;
private readonly IDiskProvider _diskProvider;
private readonly IRecycleBinProvider _recycleBinProvider;
private readonly ISeriesService _seriesService;
- private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification;
+ private readonly IUpgradableSpecification _upgradableSpecification;
private readonly Logger _logger;
public EpisodeFileModule(IBroadcastSignalRMessage signalRBroadcaster,
@@ -30,7 +30,7 @@ namespace NzbDrone.Api.EpisodeFiles
IDiskProvider diskProvider,
IRecycleBinProvider recycleBinProvider,
ISeriesService seriesService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification upgradableSpecification,
Logger logger)
: base(signalRBroadcaster)
{
@@ -38,7 +38,7 @@ namespace NzbDrone.Api.EpisodeFiles
_diskProvider = diskProvider;
_recycleBinProvider = recycleBinProvider;
_seriesService = seriesService;
- _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _upgradableSpecification = upgradableSpecification;
_logger = logger;
GetResourceById = GetEpisodeFile;
GetResourceAll = GetEpisodeFiles;
@@ -95,4 +95,4 @@ namespace NzbDrone.Api.EpisodeFiles
BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.Id);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs
index bd856776d..a284a8d4f 100644
--- a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs
+++ b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs
@@ -1,7 +1,10 @@
-using System;
+using System;
using System.IO;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Qualities;
+using NzbDrone.Core.Languages;
namespace NzbDrone.Api.EpisodeFiles
{
@@ -15,13 +18,14 @@ namespace NzbDrone.Api.EpisodeFiles
public DateTime DateAdded { get; set; }
public string SceneName { get; set; }
public QualityModel Quality { get; set; }
+ public Language Language { get; set; }
public bool QualityCutoffNotMet { get; set; }
}
public static class EpisodeFileResourceMapper
{
- private static EpisodeFileResource ToResource(this Core.MediaFiles.EpisodeFile model)
+ private static EpisodeFileResource ToResource(this EpisodeFile model)
{
if (model == null) return null;
@@ -41,7 +45,7 @@ namespace NzbDrone.Api.EpisodeFiles
};
}
- public static EpisodeFileResource ToResource(this Core.MediaFiles.EpisodeFile model, Core.Tv.Series series, Core.DecisionEngine.IQualityUpgradableSpecification qualityUpgradableSpecification)
+ public static EpisodeFileResource ToResource(this EpisodeFile model, Core.Tv.Series series, IUpgradableSpecification upgradableSpecification)
{
if (model == null) return null;
@@ -57,7 +61,8 @@ namespace NzbDrone.Api.EpisodeFiles
DateAdded = model.DateAdded,
SceneName = model.SceneName,
Quality = model.Quality,
- QualityCutoffNotMet = qualityUpgradableSpecification.CutoffNotMet(series.Profile.Value, model.Quality)
+ Language = model.Language,
+ QualityCutoffNotMet = upgradableSpecification.CutoffNotMet(series.Profile.Value, series.LanguageProfile.Value, model.Quality, model.Language)
};
}
}
diff --git a/src/NzbDrone.Api/Episodes/EpisodeModule.cs b/src/NzbDrone.Api/Episodes/EpisodeModule.cs
index 7f6f5692c..fb4b8933d 100644
--- a/src/NzbDrone.Api/Episodes/EpisodeModule.cs
+++ b/src/NzbDrone.Api/Episodes/EpisodeModule.cs
@@ -1,5 +1,5 @@
-using System.Collections.Generic;
-using NzbDrone.Api.REST;
+using System.Collections.Generic;
+using Lidarr.Http.REST;
using NzbDrone.Core.Tv;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.SignalR;
@@ -10,7 +10,7 @@ namespace NzbDrone.Api.Episodes
{
public EpisodeModule(ISeriesService seriesService,
IEpisodeService episodeService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(episodeService, seriesService, qualityUpgradableSpecification, signalRBroadcaster)
{
diff --git a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs b/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs
index d4c1deb27..a170f49f8 100644
--- a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs
+++ b/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Common.Extensions;
using NzbDrone.Api.EpisodeFiles;
using NzbDrone.Api.Series;
@@ -9,20 +9,21 @@ using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Tv;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.Episodes
{
- public abstract class EpisodeModuleWithSignalR : NzbDroneRestModuleWithSignalR,
+ public abstract class EpisodeModuleWithSignalR : LidarrRestModuleWithSignalR,
IHandle,
IHandle
{
protected readonly IEpisodeService _episodeService;
protected readonly ISeriesService _seriesService;
- protected readonly IQualityUpgradableSpecification _qualityUpgradableSpecification;
+ protected readonly IUpgradableSpecification _qualityUpgradableSpecification;
protected EpisodeModuleWithSignalR(IEpisodeService episodeService,
ISeriesService seriesService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(signalRBroadcaster)
{
@@ -35,7 +36,7 @@ namespace NzbDrone.Api.Episodes
protected EpisodeModuleWithSignalR(IEpisodeService episodeService,
ISeriesService seriesService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster,
string resource)
: base(signalRBroadcaster, resource)
diff --git a/src/NzbDrone.Api/Episodes/EpisodeResource.cs b/src/NzbDrone.Api/Episodes/EpisodeResource.cs
index 3ff489f38..8faadb272 100644
--- a/src/NzbDrone.Api/Episodes/EpisodeResource.cs
+++ b/src/NzbDrone.Api/Episodes/EpisodeResource.cs
@@ -1,9 +1,9 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using NzbDrone.Api.EpisodeFiles;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Api.Series;
using NzbDrone.Core.Tv;
diff --git a/src/NzbDrone.Api/ErrorManagement/NzbDroneErrorPipeline.cs b/src/NzbDrone.Api/ErrorManagement/NzbDroneErrorPipeline.cs
deleted file mode 100644
index d98925f8e..000000000
--- a/src/NzbDrone.Api/ErrorManagement/NzbDroneErrorPipeline.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using System;
-using System.Data.SQLite;
-using FluentValidation;
-using NLog;
-using Nancy;
-using NzbDrone.Api.Extensions;
-using NzbDrone.Core.Exceptions;
-using HttpStatusCode = Nancy.HttpStatusCode;
-
-namespace NzbDrone.Api.ErrorManagement
-{
- public class NzbDroneErrorPipeline
- {
- private readonly Logger _logger;
-
- public NzbDroneErrorPipeline(Logger logger)
- {
- _logger = logger;
- }
-
- public Response HandleException(NancyContext context, Exception exception)
- {
- _logger.Trace("Handling Exception");
-
- var apiException = exception as ApiException;
-
- if (apiException != null)
- {
- _logger.Warn(apiException, "API Error");
- return apiException.ToErrorResponse();
- }
-
- var validationException = exception as ValidationException;
-
- if (validationException != null)
- {
- _logger.Warn("Invalid request {0}", validationException.Message);
-
- return validationException.Errors.AsResponse(HttpStatusCode.BadRequest);
- }
-
- var clientException = exception as NzbDroneClientException;
-
- if (clientException != null)
- {
- return new ErrorModel
- {
- Message = exception.Message,
- Description = exception.ToString()
- }.AsResponse((HttpStatusCode)clientException.StatusCode);
- }
-
- var sqLiteException = exception as SQLiteException;
-
- if (sqLiteException != null)
- {
- if (context.Request.Method == "PUT" || context.Request.Method == "POST")
- {
- if (sqLiteException.Message.Contains("constraint failed"))
- return new ErrorModel
- {
- Message = exception.Message,
- }.AsResponse(HttpStatusCode.Conflict);
- }
-
- _logger.Error(sqLiteException, "[{0} {1}]", context.Request.Method, context.Request.Path);
- }
-
- _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path);
-
- return new ErrorModel
- {
- Message = exception.Message,
- Description = exception.ToString()
- }.AsResponse(HttpStatusCode.InternalServerError);
- }
- }
-}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs
deleted file mode 100644
index 00488657b..000000000
--- a/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System;
-using Nancy;
-using Nancy.Bootstrapper;
-using NzbDrone.Common.EnvironmentInfo;
-
-namespace NzbDrone.Api.Extensions.Pipelines
-{
- public class NzbDroneVersionPipeline : IRegisterNancyPipeline
- {
- public int Order => 0;
-
- public void Register(IPipelines pipelines)
- {
- pipelines.AfterRequest.AddItemToStartOfPipeline((Action) Handle);
- }
-
- private void Handle(NancyContext context)
- {
- if (!context.Response.Headers.ContainsKey("X-ApplicationVersion"))
- {
- context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString());
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/FileSystem/FileSystemModule.cs b/src/NzbDrone.Api/FileSystem/FileSystemModule.cs
index 392c3c0c5..4e3ac967e 100644
--- a/src/NzbDrone.Api/FileSystem/FileSystemModule.cs
+++ b/src/NzbDrone.Api/FileSystem/FileSystemModule.cs
@@ -1,8 +1,8 @@
-using System;
+using System;
using System.IO;
using System.Linq;
using Nancy;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.MediaFiles;
@@ -73,4 +73,4 @@ namespace NzbDrone.Api.FileSystem
}).AsResponse();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs
deleted file mode 100644
index 974e117f9..000000000
--- a/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using System;
-using System.IO;
-using System.Text.RegularExpressions;
-using Nancy;
-using NLog;
-using NzbDrone.Common.Disk;
-using NzbDrone.Common.EnvironmentInfo;
-using NzbDrone.Core.Configuration;
-
-namespace NzbDrone.Api.Frontend.Mappers
-{
- public class LoginHtmlMapper : StaticResourceMapperBase
- {
- private readonly IDiskProvider _diskProvider;
- private readonly IConfigFileProvider _configFileProvider;
- private readonly Func _cacheBreakProviderFactory;
- private readonly string _indexPath;
- private static readonly Regex ReplaceRegex = new Regex("(?<=(?:href|src|data-main)=\").*?(?=\")", RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
- private static string URL_BASE;
- private string _generatedContent;
-
- public LoginHtmlMapper(IAppFolderInfo appFolderInfo,
- IDiskProvider diskProvider,
- IConfigFileProvider configFileProvider,
- Func cacheBreakProviderFactory,
- Logger logger)
- : base(diskProvider, logger)
- {
- _diskProvider = diskProvider;
- _configFileProvider = configFileProvider;
- _cacheBreakProviderFactory = cacheBreakProviderFactory;
- _indexPath = Path.Combine(appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, "login.html");
-
- URL_BASE = configFileProvider.UrlBase;
- }
-
- public override string Map(string resourceUrl)
- {
- return _indexPath;
- }
-
- public override bool CanHandle(string resourceUrl)
- {
- return resourceUrl.StartsWith("/login");
- }
-
- public override Response GetResponse(string resourceUrl)
- {
- var response = base.GetResponse(resourceUrl);
- response.Headers["X-UA-Compatible"] = "IE=edge";
-
- return response;
- }
-
- protected override Stream GetContentStream(string filePath)
- {
- var text = GetLoginText();
-
- var stream = new MemoryStream();
- var writer = new StreamWriter(stream);
- writer.Write(text);
- writer.Flush();
- stream.Position = 0;
- return stream;
- }
-
- private string GetLoginText()
- {
- if (RuntimeInfo.IsProduction && _generatedContent != null)
- {
- return _generatedContent;
- }
-
- var text = _diskProvider.ReadAllText(_indexPath);
-
- var cacheBreakProvider = _cacheBreakProviderFactory();
-
- text = ReplaceRegex.Replace(text, match =>
- {
- var url = cacheBreakProvider.AddCacheBreakerToPath(match.Value);
- return URL_BASE + url;
- });
-
- _generatedContent = text;
-
- return _generatedContent;
- }
- }
-}
diff --git a/src/NzbDrone.Api/Health/HealthModule.cs b/src/NzbDrone.Api/Health/HealthModule.cs
index 2699fa7d6..d5fa9140d 100644
--- a/src/NzbDrone.Api/Health/HealthModule.cs
+++ b/src/NzbDrone.Api/Health/HealthModule.cs
@@ -1,12 +1,13 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.HealthCheck;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.Health
{
- public class HealthModule : NzbDroneRestModuleWithSignalR,
+ public class HealthModule : LidarrRestModuleWithSignalR,
IHandle
{
private readonly IHealthCheckService _healthCheckService;
@@ -28,4 +29,4 @@ namespace NzbDrone.Api.Health
BroadcastResourceChange(ModelAction.Sync);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Health/HealthResource.cs b/src/NzbDrone.Api/Health/HealthResource.cs
index e860cb778..32a78bd53 100644
--- a/src/NzbDrone.Api/Health/HealthResource.cs
+++ b/src/NzbDrone.Api/Health/HealthResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Common.Http;
using NzbDrone.Core.HealthCheck;
diff --git a/src/NzbDrone.Api/History/HistoryModule.cs b/src/NzbDrone.Api/History/HistoryModule.cs
index 8d2c8bb21..59e58df27 100644
--- a/src/NzbDrone.Api/History/HistoryModule.cs
+++ b/src/NzbDrone.Api/History/HistoryModule.cs
@@ -1,29 +1,30 @@
-using System;
+using System;
using Nancy;
using NzbDrone.Api.Episodes;
using NzbDrone.Api.Albums;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
using NzbDrone.Api.Series;
using NzbDrone.Api.Music;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download;
using NzbDrone.Core.History;
+using Lidarr.Http;
namespace NzbDrone.Api.History
{
- public class HistoryModule : NzbDroneRestModule
+ public class HistoryModule : LidarrRestModule
{
private readonly IHistoryService _historyService;
- private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification;
+ private readonly IUpgradableSpecification _upgradableSpecification;
private readonly IFailedDownloadService _failedDownloadService;
public HistoryModule(IHistoryService historyService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IFailedDownloadService failedDownloadService)
{
_historyService = historyService;
- _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _upgradableSpecification = qualityUpgradableSpecification;
_failedDownloadService = failedDownloadService;
GetResourcePaged = GetHistory;
@@ -39,7 +40,10 @@ namespace NzbDrone.Api.History
if (model.Artist != null)
{
- resource.QualityCutoffNotMet = _qualityUpgradableSpecification.CutoffNotMet(model.Artist.Profile.Value, model.Quality);
+ resource.QualityCutoffNotMet = _upgradableSpecification.CutoffNotMet(model.Artist.Profile.Value,
+ model.Artist.LanguageProfile,
+ model.Quality,
+ model.Language);
}
return resource;
diff --git a/src/NzbDrone.Api/History/HistoryResource.cs b/src/NzbDrone.Api/History/HistoryResource.cs
index 1279641be..327331be9 100644
--- a/src/NzbDrone.Api/History/HistoryResource.cs
+++ b/src/NzbDrone.Api/History/HistoryResource.cs
@@ -1,12 +1,13 @@
-using System;
+using System;
using System.Collections.Generic;
using NzbDrone.Api.Episodes;
using NzbDrone.Api.Albums;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Api.Series;
using NzbDrone.Api.Music;
using NzbDrone.Core.History;
using NzbDrone.Core.Qualities;
+using NzbDrone.Core.Languages;
namespace NzbDrone.Api.History
@@ -20,6 +21,7 @@ namespace NzbDrone.Api.History
public bool QualityCutoffNotMet { get; set; }
public DateTime Date { get; set; }
public string DownloadId { get; set; }
+ public Language Language { get; set; }
public HistoryEventType EventType { get; set; }
diff --git a/src/NzbDrone.Api/Indexers/ReleaseModule.cs b/src/NzbDrone.Api/Indexers/ReleaseModule.cs
index 858263b4e..6b90afbbb 100644
--- a/src/NzbDrone.Api/Indexers/ReleaseModule.cs
+++ b/src/NzbDrone.Api/Indexers/ReleaseModule.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using FluentValidation;
using Nancy;
@@ -10,7 +10,7 @@ using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model;
using Nancy.ModelBinding;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
using NzbDrone.Common.Cache;
using HttpStatusCode = System.Net.HttpStatusCode;
@@ -117,4 +117,4 @@ namespace NzbDrone.Api.Indexers
return base.MapDecision(decision, initialWeight);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs b/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs
index 32344ef34..47fae1b06 100644
--- a/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs
+++ b/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs
@@ -1,9 +1,10 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Core.DecisionEngine;
+using Lidarr.Http;
namespace NzbDrone.Api.Indexers
{
- public abstract class ReleaseModuleBase : NzbDroneRestModule
+ public abstract class ReleaseModuleBase : LidarrRestModule
{
protected virtual List MapDecisions(IEnumerable decisions)
{
diff --git a/src/NzbDrone.Api/Indexers/ReleasePushModule.cs b/src/NzbDrone.Api/Indexers/ReleasePushModule.cs
index c25e45726..65c7d46d5 100644
--- a/src/NzbDrone.Api/Indexers/ReleasePushModule.cs
+++ b/src/NzbDrone.Api/Indexers/ReleasePushModule.cs
@@ -1,4 +1,4 @@
-using Nancy;
+using Nancy;
using Nancy.ModelBinding;
using FluentValidation;
using NzbDrone.Core.DecisionEngine;
@@ -6,7 +6,7 @@ using NzbDrone.Core.Download;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
using NLog;
namespace NzbDrone.Api.Indexers
diff --git a/src/NzbDrone.Api/Indexers/ReleaseResource.cs b/src/NzbDrone.Api/Indexers/ReleaseResource.cs
index a8f8cdfab..544a86325 100644
--- a/src/NzbDrone.Api/Indexers/ReleaseResource.cs
+++ b/src/NzbDrone.Api/Indexers/ReleaseResource.cs
@@ -1,10 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
using Newtonsoft.Json;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.DecisionEngine;
using System.Linq;
@@ -154,4 +155,4 @@ namespace NzbDrone.Api.Indexers
return model;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Logs/LogFileModuleBase.cs b/src/NzbDrone.Api/Logs/LogFileModuleBase.cs
index d8a12d1bf..4af69e543 100644
--- a/src/NzbDrone.Api/Logs/LogFileModuleBase.cs
+++ b/src/NzbDrone.Api/Logs/LogFileModuleBase.cs
@@ -1,14 +1,15 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using NzbDrone.Common.Disk;
using Nancy;
using Nancy.Responses;
using NzbDrone.Core.Configuration;
+using Lidarr.Http;
namespace NzbDrone.Api.Logs
{
- public abstract class LogFileModuleBase : NzbDroneRestModule
+ public abstract class LogFileModuleBase : LidarrRestModule
{
protected const string LOGFILE_ROUTE = @"/(?[-.a-zA-Z0-9]+?\.txt)";
@@ -68,4 +69,4 @@ namespace NzbDrone.Api.Logs
protected abstract string DownloadUrlRoot { get; }
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Logs/LogFileResource.cs b/src/NzbDrone.Api/Logs/LogFileResource.cs
index 9f67c8af7..e0ccc1924 100644
--- a/src/NzbDrone.Api/Logs/LogFileResource.cs
+++ b/src/NzbDrone.Api/Logs/LogFileResource.cs
@@ -1,5 +1,5 @@
-using System;
-using NzbDrone.Api.REST;
+using System;
+using Lidarr.Http.REST;
namespace NzbDrone.Api.Logs
{
diff --git a/src/NzbDrone.Api/Logs/LogModule.cs b/src/NzbDrone.Api/Logs/LogModule.cs
index 88ead3ec0..323333132 100644
--- a/src/NzbDrone.Api/Logs/LogModule.cs
+++ b/src/NzbDrone.Api/Logs/LogModule.cs
@@ -1,8 +1,10 @@
-using NzbDrone.Core.Instrumentation;
+using NzbDrone.Core.Instrumentation;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Logs
{
- public class LogModule : NzbDroneRestModule
+ public class LogModule : LidarrRestModule
{
private readonly ILogService _logService;
@@ -49,4 +51,4 @@ namespace NzbDrone.Api.Logs
return ApplyToPage(_logService.Paged, pageSpec, LogResourceMapper.ToResource);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Logs/LogResource.cs b/src/NzbDrone.Api/Logs/LogResource.cs
index 504a45839..4f98dfeb8 100644
--- a/src/NzbDrone.Api/Logs/LogResource.cs
+++ b/src/NzbDrone.Api/Logs/LogResource.cs
@@ -1,5 +1,5 @@
-using System;
-using NzbDrone.Api.REST;
+using System;
+using Lidarr.Http.REST;
namespace NzbDrone.Api.Logs
{
diff --git a/src/NzbDrone.Api/ManualImport/ManualImportModule.cs b/src/NzbDrone.Api/ManualImport/ManualImportModule.cs
index bcd99de1b..1b2201c4b 100644
--- a/src/NzbDrone.Api/ManualImport/ManualImportModule.cs
+++ b/src/NzbDrone.Api/ManualImport/ManualImportModule.cs
@@ -2,10 +2,11 @@ using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.MediaFiles.TrackImport.Manual;
using NzbDrone.Core.Qualities;
+using Lidarr.Http;
namespace NzbDrone.Api.ManualImport
{
- public class ManualImportModule : NzbDroneRestModule
+ public class ManualImportModule : LidarrRestModule
{
private readonly IManualImportService _manualImportService;
diff --git a/src/NzbDrone.Api/ManualImport/ManualImportResource.cs b/src/NzbDrone.Api/ManualImport/ManualImportResource.cs
index 99aeb0897..e1844d718 100644
--- a/src/NzbDrone.Api/ManualImport/ManualImportResource.cs
+++ b/src/NzbDrone.Api/ManualImport/ManualImportResource.cs
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Api.Episodes;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Api.Series;
using NzbDrone.Common.Crypto;
using NzbDrone.Core.DecisionEngine;
diff --git a/src/NzbDrone.Api/Music/ArtistBulkImportModule.cs b/src/NzbDrone.Api/Music/ArtistBulkImportModule.cs
index c2d92aae7..fe0a4542d 100644
--- a/src/NzbDrone.Api/Music/ArtistBulkImportModule.cs
+++ b/src/NzbDrone.Api/Music/ArtistBulkImportModule.cs
@@ -1,8 +1,8 @@
using System.Collections;
using System.Collections.Generic;
using Nancy;
-using NzbDrone.Api.REST;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.REST;
+using Lidarr.Http.Extensions;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Parser;
@@ -16,6 +16,7 @@ using NzbDrone.Core.MediaFiles.TrackImport;
using NzbDrone.Core.RootFolders;
using NzbDrone.Common.Cache;
using NzbDrone.Core.Music;
+using Lidarr.Http;
namespace NzbDrone.Api.Music
{
@@ -28,7 +29,7 @@ namespace NzbDrone.Api.Music
}
}
- public class MusicBulkImportModule : NzbDroneRestModule
+ public class MusicBulkImportModule : LidarrRestModule
{
private readonly ISearchForNewArtist _searchProxy;
private readonly IRootFolderService _rootFolderService;
diff --git a/src/NzbDrone.Api/Music/ArtistEditorModule.cs b/src/NzbDrone.Api/Music/ArtistEditorModule.cs
index ca8ec3598..6d250f25e 100644
--- a/src/NzbDrone.Api/Music/ArtistEditorModule.cs
+++ b/src/NzbDrone.Api/Music/ArtistEditorModule.cs
@@ -1,7 +1,7 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using Nancy;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
using NzbDrone.Core.Music;
namespace NzbDrone.Api.Music
diff --git a/src/NzbDrone.Api/Music/ArtistLookupModule.cs b/src/NzbDrone.Api/Music/ArtistLookupModule.cs
index faa0eca09..c7bec8f84 100644
--- a/src/NzbDrone.Api/Music/ArtistLookupModule.cs
+++ b/src/NzbDrone.Api/Music/ArtistLookupModule.cs
@@ -1,15 +1,16 @@
-using Nancy;
-using NzbDrone.Api.Extensions;
+using Nancy;
+using Lidarr.Http.Extensions;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
+using Lidarr.Http;
namespace NzbDrone.Api.Music
{
- public class ArtistLookupModule : NzbDroneRestModule
+ public class ArtistLookupModule : LidarrRestModule
{
private readonly ISearchForNewArtist _searchProxy;
diff --git a/src/NzbDrone.Api/Music/ArtistModule.cs b/src/NzbDrone.Api/Music/ArtistModule.cs
index 91ffc0f76..3e1b97b65 100644
--- a/src/NzbDrone.Api/Music/ArtistModule.cs
+++ b/src/NzbDrone.Api/Music/ArtistModule.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
@@ -14,10 +14,12 @@ using NzbDrone.Core.ArtistStats;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Music
{
- public class ArtistModule : NzbDroneRestModuleWithSignalR,
+ public class ArtistModule : LidarrRestModuleWithSignalR,
IHandle,
IHandle,
IHandle,
@@ -39,9 +41,9 @@ namespace NzbDrone.Api.Music
RootFolderValidator rootFolderValidator,
ArtistPathValidator seriesPathValidator,
ArtistExistsValidator artistExistsValidator,
- DroneFactoryValidator droneFactoryValidator,
SeriesAncestorValidator seriesAncestorValidator,
- ProfileExistsValidator profileExistsValidator
+ ProfileExistsValidator profileExistsValidator,
+ LanguageProfileExistsValidator languageProfileExistsValidator
)
: base(signalRBroadcaster)
{
@@ -56,19 +58,20 @@ namespace NzbDrone.Api.Music
CreateResource = AddArtist;
UpdateResource = UpdatArtist;
DeleteResource = DeleteArtist;
-
- Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.ProfileId));
+
+ SharedValidator.RuleFor(s => s.ProfileId).ValidId();
+ SharedValidator.RuleFor(s => s.LanguageProfileId);
SharedValidator.RuleFor(s => s.Path)
.Cascade(CascadeMode.StopOnFirstFailure)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(seriesPathValidator)
- .SetValidator(droneFactoryValidator)
.SetValidator(seriesAncestorValidator)
.When(s => !s.Path.IsNullOrWhiteSpace());
SharedValidator.RuleFor(s => s.ProfileId).SetValidator(profileExistsValidator);
+ SharedValidator.RuleFor(s => s.LanguageProfileId).SetValidator(languageProfileExistsValidator);
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.RootFolderPath).IsValidPath().When(s => s.Path.IsNullOrWhiteSpace());
diff --git a/src/NzbDrone.Api/Music/ArtistResource.cs b/src/NzbDrone.Api/Music/ArtistResource.cs
index 6d9c447f5..47537cf71 100644
--- a/src/NzbDrone.Api/Music/ArtistResource.cs
+++ b/src/NzbDrone.Api/Music/ArtistResource.cs
@@ -1,5 +1,4 @@
-using NzbDrone.Api.REST;
-using NzbDrone.Api.Series;
+using Lidarr.Http.REST;
using NzbDrone.Api.Albums;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.Music;
@@ -44,6 +43,7 @@ namespace NzbDrone.Api.Music
//View & Edit
public string Path { get; set; }
public int ProfileId { get; set; }
+ public int LanguageProfileId { get; set; }
//Editing Only
public bool AlbumFolder { get; set; }
@@ -97,6 +97,7 @@ namespace NzbDrone.Api.Music
Path = model.Path,
ProfileId = model.ProfileId,
+ LanguageProfileId = model.LanguageProfileId,
Monitored = model.Monitored,
AlbumFolder = model.AlbumFolder,
@@ -154,6 +155,7 @@ namespace NzbDrone.Api.Music
Path = resource.Path,
ProfileId = resource.ProfileId,
+ LanguageProfileId = resource.LanguageProfileId,
AlbumFolder = resource.AlbumFolder,
Monitored = resource.Monitored,
diff --git a/src/NzbDrone.Api/Music/ListImport.cs b/src/NzbDrone.Api/Music/ListImport.cs
index 456f95243..8488456fd 100644
--- a/src/NzbDrone.Api/Music/ListImport.cs
+++ b/src/NzbDrone.Api/Music/ListImport.cs
@@ -1,8 +1,8 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using Nancy;
using Nancy.Extensions;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
using NzbDrone.Core.Music;
namespace NzbDrone.Api.Music
@@ -27,4 +27,4 @@ namespace NzbDrone.Api.Music
return _artistService.AddArtists(Artists).ToResource().AsResponse(HttpStatusCode.Accepted);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/NancyBootstrapper.cs b/src/NzbDrone.Api/NancyBootstrapper.cs
deleted file mode 100644
index 1415dd4c2..000000000
--- a/src/NzbDrone.Api/NancyBootstrapper.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using System.Linq;
-using Nancy.Bootstrapper;
-using Nancy.Diagnostics;
-using NLog;
-using NzbDrone.Api.Extensions.Pipelines;
-using NzbDrone.Common.EnvironmentInfo;
-using NzbDrone.Common.Instrumentation;
-using NzbDrone.Core.Instrumentation;
-using NzbDrone.Core.Lifecycle;
-using NzbDrone.Core.Messaging.Events;
-using TinyIoC;
-
-namespace NzbDrone.Api
-{
- public class NancyBootstrapper : TinyIoCNancyBootstrapper
- {
- private readonly TinyIoCContainer _tinyIoCContainer;
- private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(NancyBootstrapper));
-
- public NancyBootstrapper(TinyIoCContainer tinyIoCContainer)
- {
- _tinyIoCContainer = tinyIoCContainer;
- }
-
- protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
- {
- Logger.Info("Starting Web Server");
-
- if (RuntimeInfo.IsProduction)
- {
- DiagnosticsHook.Disable(pipelines);
- }
-
- RegisterPipelines(pipelines);
-
- container.Resolve().Register();
- container.Resolve().PublishEvent(new ApplicationStartedEvent());
- }
-
- private void RegisterPipelines(IPipelines pipelines)
- {
- var pipelineRegistrars = _tinyIoCContainer.ResolveAll().OrderBy(v => v.Order).ToList();
-
- foreach (var registerNancyPipeline in pipelineRegistrars)
- {
- registerNancyPipeline.Register(pipelines);
- }
- }
-
- protected override TinyIoCContainer GetApplicationContainer()
- {
- return _tinyIoCContainer;
- }
-
- protected override DiagnosticsConfiguration DiagnosticsConfiguration => new DiagnosticsConfiguration { Password = @"password" };
-
- protected override byte[] FavIcon => null;
- }
-}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj
index 47bed9e5e..90349629f 100644
--- a/src/NzbDrone.Api/NzbDrone.Api.csproj
+++ b/src/NzbDrone.Api/NzbDrone.Api.csproj
@@ -90,20 +90,10 @@
-
-
-
-
-
-
-
-
-
-
@@ -119,12 +109,6 @@
-
-
-
-
-
-
@@ -164,31 +148,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -206,14 +165,10 @@
-
-
-
-
@@ -228,15 +183,8 @@
-
-
-
-
-
-
-
@@ -254,12 +202,8 @@
-
-
-
-
@@ -287,6 +231,10 @@
{7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}
NzbDrone.SignalR
+
+ {5370bff7-1bd7-46bc-af06-7d9ea5cda1d6}
+ Lidarr.Http
+
diff --git a/src/NzbDrone.Api/NzbDroneRestModule.cs b/src/NzbDrone.Api/NzbDroneRestModule.cs
deleted file mode 100644
index 4cc103d95..000000000
--- a/src/NzbDrone.Api/NzbDroneRestModule.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System;
-using NzbDrone.Api.REST;
-using NzbDrone.Api.Validation;
-using NzbDrone.Core.Datastore;
-
-namespace NzbDrone.Api
-{
- public abstract class NzbDroneRestModule : RestModule where TResource : RestResource, new()
- {
- protected string Resource { get; private set; }
-
- protected NzbDroneRestModule()
- : this(new TResource().ResourceName)
- {
- }
-
- protected NzbDroneRestModule(string resource)
- : base("/api/" + resource.Trim('/'))
- {
- Resource = resource;
- PostValidator.RuleFor(r => r.Id).IsZero();
- PutValidator.RuleFor(r => r.Id).ValidId();
- }
-
- protected PagingResource ApplyToPage(Func, PagingSpec> function, PagingSpec pagingSpec, Converter mapper)
- {
- pagingSpec = function(pagingSpec);
-
- return new PagingResource
- {
- Page = pagingSpec.Page,
- PageSize = pagingSpec.PageSize,
- SortDirection = pagingSpec.SortDirection,
- SortKey = pagingSpec.SortKey,
- TotalRecords = pagingSpec.TotalRecords,
- Records = pagingSpec.Records.ConvertAll(mapper)
- };
- }
- }
-}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/NzbDroneRestModuleWithSignalR.cs b/src/NzbDrone.Api/NzbDroneRestModuleWithSignalR.cs
deleted file mode 100644
index a2061a770..000000000
--- a/src/NzbDrone.Api/NzbDroneRestModuleWithSignalR.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using NzbDrone.Api.REST;
-using NzbDrone.Core.Datastore;
-using NzbDrone.Core.Datastore.Events;
-using NzbDrone.Core.Messaging.Events;
-using NzbDrone.SignalR;
-
-namespace NzbDrone.Api
-{
- public abstract class NzbDroneRestModuleWithSignalR : NzbDroneRestModule, IHandle>
- where TResource : RestResource, new()
- where TModel : ModelBase, new()
- {
- private readonly IBroadcastSignalRMessage _signalRBroadcaster;
-
- protected NzbDroneRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster)
- {
- _signalRBroadcaster = signalRBroadcaster;
- }
-
- protected NzbDroneRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster, string resource)
- : base(resource)
- {
- _signalRBroadcaster = signalRBroadcaster;
- }
-
- public void Handle(ModelEvent message)
- {
- if (message.Action == ModelAction.Deleted || message.Action == ModelAction.Sync)
- {
- BroadcastResourceChange(message.Action);
- }
-
- BroadcastResourceChange(message.Action, message.Model.Id);
- }
-
- protected void BroadcastResourceChange(ModelAction action, int id)
- {
- var resource = GetResourceById(id);
- BroadcastResourceChange(action, resource);
- }
-
-
- protected void BroadcastResourceChange(ModelAction action, TResource resource)
- {
- var signalRMessage = new SignalRMessage
- {
- Name = Resource,
- Body = new ResourceChangeMessage(resource, action)
- };
-
- _signalRBroadcaster.BroadcastMessage(signalRMessage);
- }
-
-
- protected void BroadcastResourceChange(ModelAction action)
- {
- var signalRMessage = new SignalRMessage
- {
- Name = Resource,
- Body = new ResourceChangeMessage(action)
- };
-
- _signalRBroadcaster.BroadcastMessage(signalRMessage);
- }
- }
-}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Parse/ParseModule.cs b/src/NzbDrone.Api/Parse/ParseModule.cs
index 486ed8ace..d3448d5ba 100644
--- a/src/NzbDrone.Api/Parse/ParseModule.cs
+++ b/src/NzbDrone.Api/Parse/ParseModule.cs
@@ -1,10 +1,11 @@
-using NzbDrone.Api.Albums;
+using NzbDrone.Api.Albums;
using NzbDrone.Api.Music;
using NzbDrone.Core.Parser;
+using Lidarr.Http;
namespace NzbDrone.Api.Parse
{
- public class ParseModule : NzbDroneRestModule
+ public class ParseModule : LidarrRestModule
{
private readonly IParsingService _parsingService;
@@ -47,4 +48,4 @@ namespace NzbDrone.Api.Parse
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Parse/ParseResource.cs b/src/NzbDrone.Api/Parse/ParseResource.cs
index df19e42de..91ab9adb6 100644
--- a/src/NzbDrone.Api/Parse/ParseResource.cs
+++ b/src/NzbDrone.Api/Parse/ParseResource.cs
@@ -1,5 +1,5 @@
-using System.Collections.Generic;
-using NzbDrone.Api.REST;
+using System.Collections.Generic;
+using Lidarr.Http.REST;
using NzbDrone.Api.Music;
using NzbDrone.Api.Albums;
using NzbDrone.Core.Parser.Model;
@@ -13,4 +13,4 @@ namespace NzbDrone.Api.Parse
public ArtistResource Artist { get; set; }
public List Albums { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs b/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs
index e7975b661..61aa00ea3 100644
--- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs
+++ b/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs
@@ -1,13 +1,14 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using FluentValidation;
using FluentValidation.Results;
-using NzbDrone.Api.REST;
-using NzbDrone.Api.Validation;
+using Lidarr.Http.REST;
+using Lidarr.Http.Validation;
using NzbDrone.Core.Profiles.Delay;
+using Lidarr.Http;
namespace NzbDrone.Api.Profiles.Delay
{
- public class DelayProfileModule : NzbDroneRestModule
+ public class DelayProfileModule : LidarrRestModule
{
private readonly IDelayProfileService _delayProfileService;
@@ -72,4 +73,4 @@ namespace NzbDrone.Api.Profiles.Delay
return _delayProfileService.All().ToResource();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs b/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs
index e35df9043..cfc1f39b9 100644
--- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs
+++ b/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Profiles.Delay;
diff --git a/src/NzbDrone.Api/Profiles/Languages/LanguageModule.cs b/src/NzbDrone.Api/Profiles/Languages/LanguageModule.cs
index 147bc69aa..63cc61362 100644
--- a/src/NzbDrone.Api/Profiles/Languages/LanguageModule.cs
+++ b/src/NzbDrone.Api/Profiles/Languages/LanguageModule.cs
@@ -1,11 +1,12 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Core.Parser;
+using NzbDrone.Core.Languages;
+using Lidarr.Http;
namespace NzbDrone.Api.Profiles.Languages
{
- public class LanguageModule : NzbDroneRestModule
+ public class LanguageModule : LidarrRestModule
{
public LanguageModule()
{
@@ -36,4 +37,4 @@ namespace NzbDrone.Api.Profiles.Languages
.ToList();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Profiles/Languages/LanguageResource.cs b/src/NzbDrone.Api/Profiles/Languages/LanguageResource.cs
index 09e5ba28c..ca1b81aed 100644
--- a/src/NzbDrone.Api/Profiles/Languages/LanguageResource.cs
+++ b/src/NzbDrone.Api/Profiles/Languages/LanguageResource.cs
@@ -1,5 +1,5 @@
-using Newtonsoft.Json;
-using NzbDrone.Api.REST;
+using Newtonsoft.Json;
+using Lidarr.Http.REST;
namespace NzbDrone.Api.Profiles.Languages
{
@@ -10,4 +10,4 @@ namespace NzbDrone.Api.Profiles.Languages
public string Name { get; set; }
public string NameLower => Name.ToLowerInvariant();
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Profiles/ProfileModule.cs b/src/NzbDrone.Api/Profiles/ProfileModule.cs
index e5803db20..35a51c82b 100644
--- a/src/NzbDrone.Api/Profiles/ProfileModule.cs
+++ b/src/NzbDrone.Api/Profiles/ProfileModule.cs
@@ -1,11 +1,13 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using FluentValidation;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Validation;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Profiles
{
- public class ProfileModule : NzbDroneRestModule
+ public class ProfileModule : LidarrRestModule
{
private readonly IProfileService _profileService;
@@ -15,7 +17,6 @@ namespace NzbDrone.Api.Profiles
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Cutoff).NotNull();
SharedValidator.RuleFor(c => c.Items).MustHaveAllowedQuality();
- SharedValidator.RuleFor(c => c.Language).ValidLanguage();
GetResourceAll = GetAll;
GetResourceById = GetById;
@@ -53,4 +54,4 @@ namespace NzbDrone.Api.Profiles
return _profileService.All().ToResource();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Profiles/ProfileResource.cs b/src/NzbDrone.Api/Profiles/ProfileResource.cs
index ee02bcb32..3660cbec0 100644
--- a/src/NzbDrone.Api/Profiles/ProfileResource.cs
+++ b/src/NzbDrone.Api/Profiles/ProfileResource.cs
@@ -1,8 +1,9 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Api.Profiles
@@ -12,7 +13,6 @@ namespace NzbDrone.Api.Profiles
public string Name { get; set; }
public Quality Cutoff { get; set; }
public List Items { get; set; }
- public Language Language { get; set; }
}
public class ProfileQualityItemResource : RestResource
@@ -33,8 +33,7 @@ namespace NzbDrone.Api.Profiles
Name = model.Name,
Cutoff = model.Cutoff,
- Items = model.Items.ConvertAll(ToResource),
- Language = model.Language
+ Items = model.Items.ConvertAll(ToResource)
};
}
@@ -59,8 +58,7 @@ namespace NzbDrone.Api.Profiles
Name = resource.Name,
Cutoff = (Quality)resource.Cutoff.Id,
- Items = resource.Items.ConvertAll(ToModel),
- Language = resource.Language
+ Items = resource.Items.ConvertAll(ToModel)
};
}
@@ -80,4 +78,4 @@ namespace NzbDrone.Api.Profiles
return models.Select(ToResource).ToList();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs b/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs
index ec5f3ae01..da8cb5797 100644
--- a/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs
+++ b/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs
@@ -1,12 +1,14 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Parser;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Profiles
{
- public class ProfileSchemaModule : NzbDroneRestModule
+ public class ProfileSchemaModule : LidarrRestModule
{
private readonly IQualityDefinitionService _qualityDefinitionService;
@@ -28,9 +30,8 @@ namespace NzbDrone.Api.Profiles
var profile = new Profile();
profile.Cutoff = Quality.Unknown;
profile.Items = items;
- profile.Language = Language.English;
return new List { profile.ToResource() };
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/ProviderModuleBase.cs b/src/NzbDrone.Api/ProviderModuleBase.cs
index fa5313b0a..0bb676896 100644
--- a/src/NzbDrone.Api/ProviderModuleBase.cs
+++ b/src/NzbDrone.Api/ProviderModuleBase.cs
@@ -1,18 +1,20 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Results;
using Nancy;
-using NzbDrone.Api.ClientSchema;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.ClientSchema;
+using Lidarr.Http.Extensions;
using NzbDrone.Common.Reflection;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation;
using Newtonsoft.Json;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api
{
- public abstract class ProviderModuleBase : NzbDroneRestModule
+ public abstract class ProviderModuleBase : LidarrRestModule
where TProviderDefinition : ProviderDefinition, new()
where TProvider : IProvider
where TProviderResource : ProviderResource, new()
diff --git a/src/NzbDrone.Api/ProviderResource.cs b/src/NzbDrone.Api/ProviderResource.cs
index 9927a09cc..3ddbd5e37 100644
--- a/src/NzbDrone.Api/ProviderResource.cs
+++ b/src/NzbDrone.Api/ProviderResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
-using NzbDrone.Api.ClientSchema;
-using NzbDrone.Api.REST;
+using System.Collections.Generic;
+using Lidarr.Http.ClientSchema;
+using Lidarr.Http.REST;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Api
@@ -17,4 +17,4 @@ namespace NzbDrone.Api
public List Presets { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Qualities/QualityDefinitionModule.cs b/src/NzbDrone.Api/Qualities/QualityDefinitionModule.cs
index 1b5351300..2ac7a7a3d 100644
--- a/src/NzbDrone.Api/Qualities/QualityDefinitionModule.cs
+++ b/src/NzbDrone.Api/Qualities/QualityDefinitionModule.cs
@@ -1,9 +1,11 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Core.Qualities;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Qualities
{
- public class QualityDefinitionModule : NzbDroneRestModule
+ public class QualityDefinitionModule : LidarrRestModule
{
private readonly IQualityDefinitionService _qualityDefinitionService;
@@ -34,4 +36,4 @@ namespace NzbDrone.Api.Qualities
return _qualityDefinitionService.All().ToResource();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs b/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs
index ea0edc0ab..684d5d29a 100644
--- a/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs
+++ b/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Api.Qualities
@@ -62,4 +62,4 @@ namespace NzbDrone.Api.Qualities
return models.Select(ToResource).ToList();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Queue/QueueActionModule.cs b/src/NzbDrone.Api/Queue/QueueActionModule.cs
index 89ca897b7..a7b0c7555 100644
--- a/src/NzbDrone.Api/Queue/QueueActionModule.cs
+++ b/src/NzbDrone.Api/Queue/QueueActionModule.cs
@@ -1,16 +1,17 @@
-using System;
+using System;
using Nancy;
using Nancy.Responses;
-using NzbDrone.Api.Extensions;
-using NzbDrone.Api.REST;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.REST;
using NzbDrone.Core.Download;
using NzbDrone.Core.Download.Pending;
using NzbDrone.Core.Download.TrackedDownloads;
using NzbDrone.Core.Queue;
+using Lidarr.Http;
namespace NzbDrone.Api.Queue
{
- public class QueueActionModule : NzbDroneRestModule
+ public class QueueActionModule : LidarrRestModule
{
private readonly IQueueService _queueService;
private readonly ITrackedDownloadService _trackedDownloadService;
diff --git a/src/NzbDrone.Api/Queue/QueueModule.cs b/src/NzbDrone.Api/Queue/QueueModule.cs
index 00e614132..aa0e062d2 100644
--- a/src/NzbDrone.Api/Queue/QueueModule.cs
+++ b/src/NzbDrone.Api/Queue/QueueModule.cs
@@ -1,14 +1,15 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Download.Pending;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Queue;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.Queue
{
- public class QueueModule : NzbDroneRestModuleWithSignalR,
+ public class QueueModule : LidarrRestModuleWithSignalR,
IHandle, IHandle
{
private readonly IQueueService _queueService;
@@ -45,4 +46,4 @@ namespace NzbDrone.Api.Queue
BroadcastResourceChange(ModelAction.Sync);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Queue/QueueResource.cs b/src/NzbDrone.Api/Queue/QueueResource.cs
index 858c10dcf..51ce30ed1 100644
--- a/src/NzbDrone.Api/Queue/QueueResource.cs
+++ b/src/NzbDrone.Api/Queue/QueueResource.cs
@@ -1,6 +1,6 @@
-using System;
+using System;
using System.Collections.Generic;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Qualities;
using NzbDrone.Api.Series;
using NzbDrone.Api.Episodes;
diff --git a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs b/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs
index a61b5f7b3..1b3506611 100644
--- a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs
+++ b/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs
@@ -1,11 +1,12 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using FluentValidation;
using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.Validation.Paths;
+using Lidarr.Http;
namespace NzbDrone.Api.RemotePathMappings
{
- public class RemotePathMappingModule : NzbDroneRestModule
+ public class RemotePathMappingModule : LidarrRestModule
{
private readonly IRemotePathMappingService _remotePathMappingService;
@@ -64,4 +65,4 @@ namespace NzbDrone.Api.RemotePathMappings
_remotePathMappingService.Update(mapping);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs b/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs
index 60c01b682..779f929b0 100644
--- a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs
+++ b/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.RemotePathMappings;
namespace NzbDrone.Api.RemotePathMappings
diff --git a/src/NzbDrone.Api/Restrictions/RestrictionModule.cs b/src/NzbDrone.Api/Restrictions/RestrictionModule.cs
index 918b3a50b..032d9540a 100644
--- a/src/NzbDrone.Api/Restrictions/RestrictionModule.cs
+++ b/src/NzbDrone.Api/Restrictions/RestrictionModule.cs
@@ -1,11 +1,12 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using FluentValidation.Results;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Restrictions;
+using Lidarr.Http;
namespace NzbDrone.Api.Restrictions
{
- public class RestrictionModule : NzbDroneRestModule
+ public class RestrictionModule : LidarrRestModule
{
private readonly IRestrictionService _restrictionService;
diff --git a/src/NzbDrone.Api/Restrictions/RestrictionResource.cs b/src/NzbDrone.Api/Restrictions/RestrictionResource.cs
index 14085e820..cd49cf623 100644
--- a/src/NzbDrone.Api/Restrictions/RestrictionResource.cs
+++ b/src/NzbDrone.Api/Restrictions/RestrictionResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Restrictions;
namespace NzbDrone.Api.Restrictions
diff --git a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs
index e87e581de..93b781126 100644
--- a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs
+++ b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs
@@ -1,12 +1,13 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using FluentValidation;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.RootFolders
{
- public class RootFolderModule : NzbDroneRestModuleWithSignalR
+ public class RootFolderModule : LidarrRestModuleWithSignalR
{
private readonly IRootFolderService _rootFolderService;
@@ -14,7 +15,6 @@ namespace NzbDrone.Api.RootFolders
IBroadcastSignalRMessage signalRBroadcaster,
RootFolderValidator rootFolderValidator,
PathExistsValidator pathExistsValidator,
- DroneFactoryValidator droneFactoryValidator,
MappedNetworkDriveValidator mappedNetworkDriveValidator,
StartupFolderValidator startupFolderValidator,
FolderWritableValidator folderWritableValidator)
@@ -31,7 +31,6 @@ namespace NzbDrone.Api.RootFolders
.Cascade(CascadeMode.StopOnFirstFailure)
.IsValidPath()
.SetValidator(rootFolderValidator)
- .SetValidator(droneFactoryValidator)
.SetValidator(mappedNetworkDriveValidator)
.SetValidator(startupFolderValidator)
.SetValidator(pathExistsValidator)
@@ -60,4 +59,4 @@ namespace NzbDrone.Api.RootFolders
_rootFolderService.Remove(id);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/RootFolders/RootFolderResource.cs b/src/NzbDrone.Api/RootFolders/RootFolderResource.cs
index 86efef529..2fefad440 100644
--- a/src/NzbDrone.Api/RootFolders/RootFolderResource.cs
+++ b/src/NzbDrone.Api/RootFolders/RootFolderResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.RootFolders;
namespace NzbDrone.Api.RootFolders
@@ -48,4 +48,4 @@ namespace NzbDrone.Api.RootFolders
return models.Select(ToResource).ToList();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs b/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs
index 93cd25ce5..20afcf722 100644
--- a/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs
+++ b/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs
@@ -1,5 +1,5 @@
-using Nancy;
-using NzbDrone.Api.Extensions;
+using Nancy;
+using Lidarr.Http.Extensions;
using NzbDrone.Core.Tv;
namespace NzbDrone.Api.SeasonPass
diff --git a/src/NzbDrone.Api/Series/SeriesEditorModule.cs b/src/NzbDrone.Api/Series/SeriesEditorModule.cs
index 87cd53113..a54ceff7e 100644
--- a/src/NzbDrone.Api/Series/SeriesEditorModule.cs
+++ b/src/NzbDrone.Api/Series/SeriesEditorModule.cs
@@ -1,7 +1,8 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using Nancy;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.Mapping;
using NzbDrone.Core.Tv;
namespace NzbDrone.Api.Series
diff --git a/src/NzbDrone.Api/Series/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs
index 693f9a360..c7f6cbc7d 100644
--- a/src/NzbDrone.Api/Series/SeriesModule.cs
+++ b/src/NzbDrone.Api/Series/SeriesModule.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
@@ -15,10 +15,12 @@ using NzbDrone.Core.Validation.Paths;
using NzbDrone.Core.DataAugmentation.Scene;
using NzbDrone.Core.Validation;
using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Series
{
- public class SeriesModule : NzbDroneRestModuleWithSignalR,
+ public class SeriesModule : LidarrRestModuleWithSignalR,
IHandle,
IHandle,
IHandle,
@@ -43,9 +45,9 @@ namespace NzbDrone.Api.Series
RootFolderValidator rootFolderValidator,
SeriesPathValidator seriesPathValidator,
SeriesExistsValidator seriesExistsValidator,
- DroneFactoryValidator droneFactoryValidator,
SeriesAncestorValidator seriesAncestorValidator,
- ProfileExistsValidator profileExistsValidator
+ ProfileExistsValidator profileExistsValidator,
+ LanguageProfileExistsValidator languageProfileExistsValidator
)
: base(signalRBroadcaster)
{
@@ -62,18 +64,19 @@ namespace NzbDrone.Api.Series
UpdateResource = UpdateSeries;
DeleteResource = DeleteSeries;
- Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.ProfileId));
+ SharedValidator.RuleFor(s => s.ProfileId).ValidId();
+ SharedValidator.RuleFor(s => s.LanguageProfileId);
SharedValidator.RuleFor(s => s.Path)
.Cascade(CascadeMode.StopOnFirstFailure)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(seriesPathValidator)
- .SetValidator(droneFactoryValidator)
.SetValidator(seriesAncestorValidator)
.When(s => !s.Path.IsNullOrWhiteSpace());
SharedValidator.RuleFor(s => s.ProfileId).SetValidator(profileExistsValidator);
+ SharedValidator.RuleFor(s => s.LanguageProfileId).SetValidator(languageProfileExistsValidator);
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.RootFolderPath).IsValidPath().When(s => s.Path.IsNullOrWhiteSpace());
diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs
index 86de03eb7..f686538c7 100644
--- a/src/NzbDrone.Api/Series/SeriesResource.cs
+++ b/src/NzbDrone.Api/Series/SeriesResource.cs
@@ -1,7 +1,7 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.Tv;
@@ -52,6 +52,7 @@ namespace NzbDrone.Api.Series
//View & Edit
public string Path { get; set; }
public int ProfileId { get; set; }
+ public int LanguageProfileId { get; set; }
//Editing Only
public bool SeasonFolder { get; set; }
@@ -126,7 +127,8 @@ namespace NzbDrone.Api.Series
Path = model.Path,
ProfileId = model.ProfileId,
-
+ LanguageProfileId = model.LanguageProfileId,
+
SeasonFolder = model.SeasonFolder,
Monitored = model.Monitored,
@@ -180,6 +182,7 @@ namespace NzbDrone.Api.Series
Path = resource.Path,
ProfileId = resource.ProfileId,
+ LanguageProfileId = resource.LanguageProfileId,
SeasonFolder = resource.SeasonFolder,
Monitored = resource.Monitored,
diff --git a/src/NzbDrone.Api/System/Backup/BackupModule.cs b/src/NzbDrone.Api/System/Backup/BackupModule.cs
index b5074793e..2260c191d 100644
--- a/src/NzbDrone.Api/System/Backup/BackupModule.cs
+++ b/src/NzbDrone.Api/System/Backup/BackupModule.cs
@@ -1,11 +1,12 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using NzbDrone.Core.Backup;
+using Lidarr.Http;
namespace NzbDrone.Api.System.Backup
{
- public class BackupModule : NzbDroneRestModule
+ public class BackupModule : LidarrRestModule
{
private readonly IBackupService _backupService;
@@ -21,9 +22,9 @@ namespace NzbDrone.Api.System.Backup
return backups.Select(b => new BackupResource
{
- Id = b.Path.GetHashCode(),
- Name = Path.GetFileName(b.Path),
- Path = b.Path,
+ Id = b.Name.GetHashCode(),
+ Name = Path.GetFileName(b.Name),
+ Path = b.Name,
Type = b.Type,
Time = b.Time
}).ToList();
diff --git a/src/NzbDrone.Api/System/Backup/BackupResource.cs b/src/NzbDrone.Api/System/Backup/BackupResource.cs
index 7eac82838..b8252e66d 100644
--- a/src/NzbDrone.Api/System/Backup/BackupResource.cs
+++ b/src/NzbDrone.Api/System/Backup/BackupResource.cs
@@ -1,5 +1,5 @@
-using System;
-using NzbDrone.Api.REST;
+using System;
+using Lidarr.Http.REST;
using NzbDrone.Core.Backup;
namespace NzbDrone.Api.System.Backup
diff --git a/src/NzbDrone.Api/System/SystemModule.cs b/src/NzbDrone.Api/System/SystemModule.cs
index c62ed3b9e..1df7c495a 100644
--- a/src/NzbDrone.Api/System/SystemModule.cs
+++ b/src/NzbDrone.Api/System/SystemModule.cs
@@ -1,6 +1,6 @@
-using Nancy;
+using Nancy;
using Nancy.Routing;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
diff --git a/src/NzbDrone.Api/System/Tasks/TaskModule.cs b/src/NzbDrone.Api/System/Tasks/TaskModule.cs
index db8c4f376..fc196fd01 100644
--- a/src/NzbDrone.Api/System/Tasks/TaskModule.cs
+++ b/src/NzbDrone.Api/System/Tasks/TaskModule.cs
@@ -1,14 +1,15 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Jobs;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.System.Tasks
{
- public class TaskModule : NzbDroneRestModuleWithSignalR, IHandle
+ public class TaskModule : LidarrRestModuleWithSignalR, IHandle
{
private readonly ITaskManager _taskManager;
diff --git a/src/NzbDrone.Api/System/Tasks/TaskResource.cs b/src/NzbDrone.Api/System/Tasks/TaskResource.cs
index fda392cae..05992d626 100644
--- a/src/NzbDrone.Api/System/Tasks/TaskResource.cs
+++ b/src/NzbDrone.Api/System/Tasks/TaskResource.cs
@@ -1,5 +1,5 @@
-using System;
-using NzbDrone.Api.REST;
+using System;
+using Lidarr.Http.REST;
namespace NzbDrone.Api.System.Tasks
{
diff --git a/src/NzbDrone.Api/Tags/TagModule.cs b/src/NzbDrone.Api/Tags/TagModule.cs
index d2a01667c..1fdd5ae01 100644
--- a/src/NzbDrone.Api/Tags/TagModule.cs
+++ b/src/NzbDrone.Api/Tags/TagModule.cs
@@ -1,12 +1,14 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Tags;
using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Tags
{
- public class TagModule : NzbDroneRestModuleWithSignalR, IHandle
+ public class TagModule : LidarrRestModuleWithSignalR, IHandle
{
private readonly ITagService _tagService;
diff --git a/src/NzbDrone.Api/Tags/TagResource.cs b/src/NzbDrone.Api/Tags/TagResource.cs
index 678107bf5..3d2599f8e 100644
--- a/src/NzbDrone.Api/Tags/TagResource.cs
+++ b/src/NzbDrone.Api/Tags/TagResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Tags;
namespace NzbDrone.Api.Tags
diff --git a/src/NzbDrone.Api/TrackFiles/TrackFileModule.cs b/src/NzbDrone.Api/TrackFiles/TrackFileModule.cs
index 6cbf8e09a..577c15ea6 100644
--- a/src/NzbDrone.Api/TrackFiles/TrackFileModule.cs
+++ b/src/NzbDrone.Api/TrackFiles/TrackFileModule.cs
@@ -1,7 +1,7 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.IO;
using NLog;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Events;
@@ -13,10 +13,12 @@ using NzbDrone.Core.Music;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.SignalR;
using System;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.TrackFiles
{
- public class TrackFileModule : NzbDroneRestModuleWithSignalR,
+ public class TrackFileModule : LidarrRestModuleWithSignalR,
IHandle
{
private readonly IMediaFileService _mediaFileService;
@@ -24,7 +26,7 @@ namespace NzbDrone.Api.TrackFiles
private readonly IRecycleBinProvider _recycleBinProvider;
private readonly ISeriesService _seriesService;
private readonly IArtistService _artistService;
- private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification;
+ private readonly IUpgradableSpecification _upgradableSpecification;
private readonly Logger _logger;
public TrackFileModule(IBroadcastSignalRMessage signalRBroadcaster,
@@ -33,7 +35,7 @@ namespace NzbDrone.Api.TrackFiles
IRecycleBinProvider recycleBinProvider,
ISeriesService seriesService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification upgradableSpecification,
Logger logger)
: base(signalRBroadcaster)
{
@@ -42,7 +44,7 @@ namespace NzbDrone.Api.TrackFiles
_recycleBinProvider = recycleBinProvider;
_seriesService = seriesService;
_artistService = artistService;
- _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _upgradableSpecification = upgradableSpecification;
_logger = logger;
GetResourceById = GetTrackFile;
GetResourceAll = GetTrackFiles;
@@ -55,7 +57,7 @@ namespace NzbDrone.Api.TrackFiles
var trackFile = _mediaFileService.Get(id);
var artist = _artistService.GetArtist(trackFile.ArtistId);
- return trackFile.ToResource(artist, _qualityUpgradableSpecification);
+ return trackFile.ToResource(artist, _upgradableSpecification);
}
private List GetTrackFiles()
@@ -69,7 +71,7 @@ namespace NzbDrone.Api.TrackFiles
var artist = _artistService.GetArtist(artistId);
- return _mediaFileService.GetFilesByArtist(artistId).ConvertAll(f => f.ToResource(artist, _qualityUpgradableSpecification));
+ return _mediaFileService.GetFilesByArtist(artistId).ConvertAll(f => f.ToResource(artist, _upgradableSpecification));
}
private void SetQuality(TrackFileResource trackFileResource)
@@ -97,4 +99,4 @@ namespace NzbDrone.Api.TrackFiles
BroadcastResourceChange(ModelAction.Updated, message.TrackFile.Id);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/TrackFiles/TrackFileResource.cs b/src/NzbDrone.Api/TrackFiles/TrackFileResource.cs
index 4c75dc429..0a3aeb66b 100644
--- a/src/NzbDrone.Api/TrackFiles/TrackFileResource.cs
+++ b/src/NzbDrone.Api/TrackFiles/TrackFileResource.cs
@@ -1,7 +1,8 @@
-using System;
+using System;
using System.IO;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Qualities;
+using NzbDrone.Core.Languages;
namespace NzbDrone.Api.TrackFiles
{
@@ -12,6 +13,7 @@ namespace NzbDrone.Api.TrackFiles
public string RelativePath { get; set; }
public string Path { get; set; }
public long Size { get; set; }
+ public Language Language { get; set; }
public DateTime DateAdded { get; set; }
//public string SceneName { get; set; }
public QualityModel Quality { get; set; }
@@ -41,7 +43,7 @@ namespace NzbDrone.Api.TrackFiles
};
}
- public static TrackFileResource ToResource(this Core.MediaFiles.TrackFile model, Core.Music.Artist artist, Core.DecisionEngine.IQualityUpgradableSpecification qualityUpgradableSpecification)
+ public static TrackFileResource ToResource(this Core.MediaFiles.TrackFile model, Core.Music.Artist artist, Core.DecisionEngine.IUpgradableSpecification upgradableSpecification)
{
if (model == null) return null;
@@ -57,7 +59,8 @@ namespace NzbDrone.Api.TrackFiles
DateAdded = model.DateAdded,
//SceneName = model.SceneName,
Quality = model.Quality,
- QualityCutoffNotMet = qualityUpgradableSpecification.CutoffNotMet(artist.Profile.Value, model.Quality)
+ Language = model.Language,
+ QualityCutoffNotMet = upgradableSpecification.CutoffNotMet(artist.Profile.Value, artist.LanguageProfile.Value, model.Quality, model.Language)
};
}
}
diff --git a/src/NzbDrone.Api/Tracks/RenameTrackModule.cs b/src/NzbDrone.Api/Tracks/RenameTrackModule.cs
index 9467ec43e..871b19ddb 100644
--- a/src/NzbDrone.Api/Tracks/RenameTrackModule.cs
+++ b/src/NzbDrone.Api/Tracks/RenameTrackModule.cs
@@ -1,10 +1,12 @@
-using System.Collections.Generic;
-using NzbDrone.Api.REST;
+using System.Collections.Generic;
+using Lidarr.Http.REST;
using NzbDrone.Core.MediaFiles;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Tracks
{
- public class RenameTrackModule : NzbDroneRestModule
+ public class RenameTrackModule : LidarrRestModule
{
private readonly IRenameTrackFileService _renameTrackFileService;
diff --git a/src/NzbDrone.Api/Tracks/RenameTrackResource.cs b/src/NzbDrone.Api/Tracks/RenameTrackResource.cs
index 12f67bb60..26d3466de 100644
--- a/src/NzbDrone.Api/Tracks/RenameTrackResource.cs
+++ b/src/NzbDrone.Api/Tracks/RenameTrackResource.cs
@@ -1,6 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
namespace NzbDrone.Api.Tracks
{
diff --git a/src/NzbDrone.Api/Tracks/TrackModule.cs b/src/NzbDrone.Api/Tracks/TrackModule.cs
index fa3222c51..acb8b3bc3 100644
--- a/src/NzbDrone.Api/Tracks/TrackModule.cs
+++ b/src/NzbDrone.Api/Tracks/TrackModule.cs
@@ -1,5 +1,5 @@
-using System.Collections.Generic;
-using NzbDrone.Api.REST;
+using System.Collections.Generic;
+using Lidarr.Http.REST;
using NzbDrone.Core.Music;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.SignalR;
@@ -10,9 +10,9 @@ namespace NzbDrone.Api.Tracks
{
public TrackModule(IArtistService artistService,
ITrackService trackService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification upgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
- : base(trackService, artistService, qualityUpgradableSpecification, signalRBroadcaster)
+ : base(trackService, artistService, upgradableSpecification, signalRBroadcaster)
{
GetResourceAll = GetTracks;
UpdateResource = SetMonitored;
diff --git a/src/NzbDrone.Api/Tracks/TrackModuleWithSignalR.cs b/src/NzbDrone.Api/Tracks/TrackModuleWithSignalR.cs
index f5926768e..81f71056d 100644
--- a/src/NzbDrone.Api/Tracks/TrackModuleWithSignalR.cs
+++ b/src/NzbDrone.Api/Tracks/TrackModuleWithSignalR.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Common.Extensions;
using NzbDrone.Api.TrackFiles;
using NzbDrone.Api.Music;
@@ -10,39 +10,41 @@ using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Music;
using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Tracks
{
- public abstract class TrackModuleWithSignalR : NzbDroneRestModuleWithSignalR,
+ public abstract class TrackModuleWithSignalR : LidarrRestModuleWithSignalR,
IHandle
{
protected readonly ITrackService _trackService;
protected readonly IArtistService _artistService;
- protected readonly IQualityUpgradableSpecification _qualityUpgradableSpecification;
+ protected readonly IUpgradableSpecification _upgradableSpecification;
protected TrackModuleWithSignalR(ITrackService trackService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification upgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(signalRBroadcaster)
{
_trackService = trackService;
_artistService = artistService;
- _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _upgradableSpecification = upgradableSpecification;
GetResourceById = GetTrack;
}
protected TrackModuleWithSignalR(ITrackService trackService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification upgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster,
string resource)
: base(signalRBroadcaster, resource)
{
_trackService = trackService;
_artistService = artistService;
- _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _upgradableSpecification = upgradableSpecification;
GetResourceById = GetTrack;
}
@@ -68,7 +70,7 @@ namespace NzbDrone.Api.Tracks
}
if (includeTrackFile && track.TrackFileId != 0)
{
- resource.TrackFile = track.TrackFile.Value.ToResource(artist, _qualityUpgradableSpecification);
+ resource.TrackFile = track.TrackFile.Value.ToResource(artist, _upgradableSpecification);
}
}
@@ -96,7 +98,7 @@ namespace NzbDrone.Api.Tracks
}
if (includeTrackFile && tracks[i].TrackFileId != 0)
{
- resource.TrackFile = tracks[i].TrackFile.Value.ToResource(artist, _qualityUpgradableSpecification);
+ resource.TrackFile = tracks[i].TrackFile.Value.ToResource(artist, _upgradableSpecification);
}
}
}
diff --git a/src/NzbDrone.Api/Tracks/TrackResource.cs b/src/NzbDrone.Api/Tracks/TrackResource.cs
index b8a007671..835a4074f 100644
--- a/src/NzbDrone.Api/Tracks/TrackResource.cs
+++ b/src/NzbDrone.Api/Tracks/TrackResource.cs
@@ -1,9 +1,9 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using NzbDrone.Api.TrackFiles;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Api.Music;
using NzbDrone.Core.Music;
diff --git a/src/NzbDrone.Api/Update/UpdateModule.cs b/src/NzbDrone.Api/Update/UpdateModule.cs
index 2104f23ea..062396d0d 100644
--- a/src/NzbDrone.Api/Update/UpdateModule.cs
+++ b/src/NzbDrone.Api/Update/UpdateModule.cs
@@ -1,11 +1,13 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Update;
+using Lidarr.Http;
+using Lidarr.Http.Mapping;
namespace NzbDrone.Api.Update
{
- public class UpdateModule : NzbDroneRestModule
+ public class UpdateModule : LidarrRestModule
{
private readonly IRecentUpdateProvider _recentUpdateProvider;
@@ -42,4 +44,4 @@ namespace NzbDrone.Api.Update
return resources;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Update/UpdateResource.cs b/src/NzbDrone.Api/Update/UpdateResource.cs
index dca6f6725..a8380828e 100644
--- a/src/NzbDrone.Api/Update/UpdateResource.cs
+++ b/src/NzbDrone.Api/Update/UpdateResource.cs
@@ -1,8 +1,8 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
using NzbDrone.Core.Update;
namespace NzbDrone.Api.Update
diff --git a/src/NzbDrone.Api/Wanted/CutoffModule.cs b/src/NzbDrone.Api/Wanted/CutoffModule.cs
index d2d08edab..4b7a22a53 100644
--- a/src/NzbDrone.Api/Wanted/CutoffModule.cs
+++ b/src/NzbDrone.Api/Wanted/CutoffModule.cs
@@ -1,8 +1,9 @@
-using NzbDrone.Api.Episodes;
+using NzbDrone.Api.Episodes;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Tv;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.Wanted
{
@@ -13,7 +14,7 @@ namespace NzbDrone.Api.Wanted
public CutoffModule(IEpisodeCutoffService episodeCutoffService,
IEpisodeService episodeService,
ISeriesService seriesService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(episodeService, seriesService, qualityUpgradableSpecification, signalRBroadcaster, "wanted/cutoff")
{
diff --git a/src/NzbDrone.Api/Wanted/MissingModule.cs b/src/NzbDrone.Api/Wanted/MissingModule.cs
index 018ecec62..7a70124ab 100644
--- a/src/NzbDrone.Api/Wanted/MissingModule.cs
+++ b/src/NzbDrone.Api/Wanted/MissingModule.cs
@@ -1,10 +1,12 @@
-using NzbDrone.Api.Episodes;
+using NzbDrone.Api.Episodes;
using NzbDrone.Api.Albums;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.ArtistStats;
using NzbDrone.Core.Music;
+using NzbDrone.Core.Tv;
using NzbDrone.SignalR;
+using Lidarr.Http;
namespace NzbDrone.Api.Wanted
{
@@ -13,7 +15,7 @@ namespace NzbDrone.Api.Wanted
public MissingModule(IAlbumService albumService,
IArtistStatisticsService artistStatisticsService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(albumService, artistStatisticsService, artistService, qualityUpgradableSpecification, signalRBroadcaster, "wanted/missing")
{
diff --git a/src/NzbDrone.Common/Disk/FileSystemLookupService.cs b/src/NzbDrone.Common/Disk/FileSystemLookupService.cs
index b262c9918..f58e7942c 100644
--- a/src/NzbDrone.Common/Disk/FileSystemLookupService.cs
+++ b/src/NzbDrone.Common/Disk/FileSystemLookupService.cs
@@ -1,8 +1,7 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
@@ -16,7 +15,6 @@ namespace NzbDrone.Common.Disk
public class FileSystemLookupService : IFileSystemLookupService
{
private readonly IDiskProvider _diskProvider;
- private readonly Logger _logger;
private readonly HashSet _setToRemove = new HashSet
{
@@ -48,10 +46,9 @@ namespace NzbDrone.Common.Disk
"@eadir"
};
- public FileSystemLookupService(IDiskProvider diskProvider, Logger logger)
+ public FileSystemLookupService(IDiskProvider diskProvider)
{
_diskProvider = diskProvider;
- _logger = logger;
}
public FileSystemResult LookupContents(string query, bool includeFiles)
@@ -154,6 +151,16 @@ namespace NzbDrone.Common.Disk
.ToList();
}
+ private static string GetVolumeName(IMount mountInfo)
+ {
+ if (mountInfo.VolumeLabel.IsNullOrWhiteSpace())
+ {
+ return mountInfo.Name;
+ }
+
+ return $"{mountInfo.Name} ({mountInfo.VolumeLabel})";
+ }
+
private string GetDirectoryPath(string path)
{
if (path.Last() != Path.DirectorySeparatorChar)
@@ -164,7 +171,7 @@ namespace NzbDrone.Common.Disk
return path;
}
- private string GetParent(string path)
+ private static string GetParent(string path)
{
var di = new DirectoryInfo(path);
diff --git a/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs
index cb432addc..e518455ef 100644
--- a/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs
+++ b/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs
@@ -1,14 +1,17 @@
-using System;
+using System;
namespace NzbDrone.Common.EnvironmentInfo
{
public interface IRuntimeInfo
{
+ DateTime StartTime { get; }
bool IsUserInteractive { get; }
bool IsAdmin { get; }
bool IsWindowsService { get; }
bool IsExiting { get; set; }
+ bool IsTray { get; }
+ RuntimeMode Mode { get; }
bool RestartPending { get; set; }
string ExecutingApplication { get; }
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs
index a53862311..9764ac270 100644
--- a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs
+++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs
@@ -1,20 +1,23 @@
-using System;
+using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Security.Principal;
using System.ServiceProcess;
using NLog;
+using NzbDrone.Common.Processes;
namespace NzbDrone.Common.EnvironmentInfo
{
public class RuntimeInfo : IRuntimeInfo
{
private readonly Logger _logger;
+ private readonly DateTime _startTime = DateTime.UtcNow;
public RuntimeInfo(IServiceProvider serviceProvider, Logger logger)
{
_logger = logger;
+
IsWindowsService = !IsUserInteractive &&
OsInfo.IsWindows &&
@@ -35,6 +38,14 @@ namespace NzbDrone.Common.EnvironmentInfo
IsProduction = InternalIsProduction();
}
+ public DateTime StartTime
+ {
+ get
+ {
+ return _startTime;
+ }
+ }
+
public static bool IsUserInteractive => Environment.UserInteractive;
bool IRuntimeInfo.IsUserInteractive => IsUserInteractive;
@@ -59,6 +70,39 @@ namespace NzbDrone.Common.EnvironmentInfo
public bool IsWindowsService { get; private set; }
public bool IsExiting { get; set; }
+
+ public bool IsTray
+ {
+ get
+ {
+ if (OsInfo.IsWindows)
+ {
+ return IsUserInteractive && Process.GetCurrentProcess().ProcessName.Equals(ProcessProvider.NZB_DRONE_PROCESS_NAME, StringComparison.InvariantCultureIgnoreCase);
+ }
+
+ return false;
+ }
+ }
+
+ public RuntimeMode Mode
+ {
+ get
+ {
+ if (IsWindowsService)
+ {
+ return RuntimeMode.Service;
+ }
+
+ if (IsTray)
+ {
+ return RuntimeMode.Tray;
+ }
+
+ return RuntimeMode.Console;
+ }
+ }
+
+
public bool RestartPending { get; set; }
public string ExecutingApplication { get; }
diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeMode.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeMode.cs
new file mode 100644
index 000000000..fc5a1867d
--- /dev/null
+++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeMode.cs
@@ -0,0 +1,9 @@
+namespace NzbDrone.Common.EnvironmentInfo
+{
+ public enum RuntimeMode
+ {
+ Console,
+ Service,
+ Tray
+ }
+}
diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj
index 611cdc4cc..0bce4df70 100644
--- a/src/NzbDrone.Common/NzbDrone.Common.csproj
+++ b/src/NzbDrone.Common/NzbDrone.Common.csproj
@@ -93,6 +93,7 @@
+
diff --git a/src/NzbDrone.Common/Serializer/Json.cs b/src/NzbDrone.Common/Serializer/Json.cs
index 31e0d3f0b..e40a8181d 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 Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@@ -10,6 +10,7 @@ namespace NzbDrone.Common.Serializer
{
private static readonly JsonSerializer Serializer;
private static readonly JsonSerializerSettings SerializerSetting;
+ private static readonly JsonSerializerSettings DeserializerSetting;
static Json()
{
@@ -24,22 +25,33 @@ namespace NzbDrone.Common.Serializer
SerializerSetting.Converters.Add(new StringEnumConverter { CamelCaseText = true });
- //SerializerSetting.Converters.Add(new IntConverter());
SerializerSetting.Converters.Add(new VersionConverter());
SerializerSetting.Converters.Add(new HttpUriConverter());
Serializer = JsonSerializer.Create(SerializerSetting);
+ DeserializerSetting = new JsonSerializerSettings
+ {
+ DateTimeZoneHandling = DateTimeZoneHandling.Utc,
+ NullValueHandling = NullValueHandling.Ignore,
+ Formatting = Formatting.Indented,
+ DefaultValueHandling = DefaultValueHandling.Include,
+ ContractResolver = new CamelCasePropertyNamesContractResolver()
+ };
+
+ DeserializerSetting.Converters.Add(new StringEnumConverter { CamelCaseText = true });
+ DeserializerSetting.Converters.Add(new VersionConverter());
+
}
public static T Deserialize(string json) where T : new()
{
- return JsonConvert.DeserializeObject(json, SerializerSetting);
+ return JsonConvert.DeserializeObject(json, DeserializerSetting);
}
public static object Deserialize(string json, Type type)
{
- return JsonConvert.DeserializeObject(json, type, SerializerSetting);
+ return JsonConvert.DeserializeObject(json, type, DeserializerSetting);
}
public static bool TryDeserialize(string json, out T result) where T : new()
@@ -78,4 +90,4 @@ namespace NzbDrone.Common.Serializer
Serialize(model, new StreamWriter(outputStream));
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs
index 3878c41c9..fbd8f295d 100644
--- a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs
+++ b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
@@ -6,6 +6,7 @@ using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
+using NzbDrone.Core.Languages;
namespace NzbDrone.Core.Test.Datastore
{
@@ -17,6 +18,7 @@ namespace NzbDrone.Core.Test.Datastore
{
var episodeFile = Builder.CreateNew()
.With(c => c.Quality = new QualityModel())
+ .With(c => c.Language = Language.English)
.BuildNew();
Db.Insert(episodeFile);
diff --git a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs
index 1633baae4..48aaebcbe 100644
--- a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs
+++ b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs
@@ -1,11 +1,14 @@
-using FizzWare.NBuilder;
+using FizzWare.NBuilder;
using NUnit.Framework;
using NzbDrone.Core.Datastore;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Test.Languages;
namespace NzbDrone.Core.Test.Datastore
{
@@ -17,18 +20,28 @@ namespace NzbDrone.Core.Test.Datastore
public void Setup()
{
var profile = new Profile
- {
- Name = "Test",
- Cutoff = Quality.MP3_320,
- Items = Qualities.QualityFixture.GetDefaultQualities()
- };
+ {
+ Name = "Test",
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ };
+
+ var languageProfile = new LanguageProfile
+ {
+ Name = "Test",
+ Languages = LanguageFixture.GetDefaultLanguages(Language.English),
+ Cutoff = Language.English
+ };
+
+
-
profile = Db.Insert(profile);
+ languageProfile = Db.Insert(languageProfile);
var series = Builder.CreateListOfSize(1)
.All()
.With(v => v.ProfileId = profile.Id)
+ .With(v => v.LanguageProfileId = languageProfile.Id)
.BuildListOfNew();
Db.InsertMany(series);
@@ -65,6 +78,7 @@ namespace NzbDrone.Core.Test.Datastore
{
Assert.IsNotNull(episode.Series);
Assert.IsFalse(episode.Series.Profile.IsLoaded);
+ Assert.IsFalse(episode.Series.LanguageProfile.IsLoaded);
}
}
@@ -100,8 +114,28 @@ namespace NzbDrone.Core.Test.Datastore
{
Assert.IsNotNull(episode.Series);
Assert.IsTrue(episode.Series.Profile.IsLoaded);
+ Assert.IsFalse(episode.Series.LanguageProfile.IsLoaded);
+ }
+ }
+
+ [Test]
+ public void should_explicit_load_languageprofile_if_joined()
+ {
+ var db = Mocker.Resolve();
+ var DataMapper = db.GetDataMapper();
+
+ var episodes = DataMapper.Query()
+ .Join(Marr.Data.QGen.JoinType.Inner, v => v.Series, (l, r) => l.SeriesId == r.Id)
+ .Join(Marr.Data.QGen.JoinType.Inner, v => v.LanguageProfile, (l, r) => l.ProfileId == r.Id)
+ .ToList();
+
+ foreach (var episode in episodes)
+ {
+ Assert.IsNotNull(episode.Series);
+ Assert.IsFalse(episode.Series.Profile.IsLoaded);
+ Assert.IsTrue(episode.Series.LanguageProfile.IsLoaded);
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/075_force_lib_updateFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/075_force_lib_updateFixture.cs
index 1d6c113b8..729c692b8 100644
--- a/src/NzbDrone.Core.Test/Datastore/Migration/075_force_lib_updateFixture.cs
+++ b/src/NzbDrone.Core.Test/Datastore/Migration/075_force_lib_updateFixture.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Test.Framework;
@@ -55,21 +55,30 @@ namespace NzbDrone.Core.Test.Datastore.Migration
{
var db = WithMigrationTestDb(c =>
{
+ c.Insert.IntoTable("Profiles").Row(new
+
+ {
+ Name = "Profile1",
+ CutOff = 0,
+ Items = "[]",
+ Language = 1
+ });
c.Insert.IntoTable("Series").Row(new
{
Tvdbid = 1,
- TvRageId =1,
- Title ="Title1",
- CleanTitle ="CleanTitle1",
- Status =1,
- Images ="",
- Path ="c:\\test",
- Monitored =1,
- SeasonFolder =1,
- Runtime= 0,
- SeriesType=0,
- UseSceneNumbering =0,
- LastInfoSync = "2000-01-01 00:00:00"
+ TvRageId = 1,
+ Title = "Title1",
+ CleanTitle = "CleanTitle1",
+ Status = 1,
+ Images = "",
+ Path = "c:\\test",
+ Monitored = 1,
+ SeasonFolder = 1,
+ Runtime = 0,
+ SeriesType = 0,
+ UseSceneNumbering = 0,
+ LastInfoSync = "2000-01-01 00:00:00",
+ ProfileId = 1
});
c.Insert.IntoTable("Series").Row(new
@@ -86,7 +95,8 @@ namespace NzbDrone.Core.Test.Datastore.Migration
Runtime = 0,
SeriesType = 0,
UseSceneNumbering = 0,
- LastInfoSync = "2000-01-01 00:00:00"
+ LastInfoSync = "2000-01-01 00:00:00",
+ ProfileId = 1
});
});
@@ -95,4 +105,4 @@ namespace NzbDrone.Core.Test.Datastore.Migration
series.Should().OnlyContain(c => c.LastInfoSync.Value.Year == 2014);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/079_dedupe_tagsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/079_dedupe_tagsFixture.cs
index e333fb9a1..ea8dcc013 100644
--- a/src/NzbDrone.Core.Test/Datastore/Migration/079_dedupe_tagsFixture.cs
+++ b/src/NzbDrone.Core.Test/Datastore/Migration/079_dedupe_tagsFixture.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Migration;
@@ -14,6 +14,15 @@ namespace NzbDrone.Core.Test.Datastore.Migration
{
var db = WithMigrationTestDb(c =>
{
+ c.Insert.IntoTable("Profiles").Row(new
+
+ {
+ Name = "Profile1",
+ CutOff = 0,
+ Items = "[]",
+ Language = 1
+ });
+
c.Insert.IntoTable("Series").Row(new
{
Tvdbid = 1,
@@ -28,12 +37,13 @@ namespace NzbDrone.Core.Test.Datastore.Migration
Runtime = 0,
SeriesType = 0,
UseSceneNumbering = 0,
- LastInfoSync = "2000-01-01 00:00:00"
+ LastInfoSync = "2000-01-01 00:00:00",
+ ProfileId = 1
});
c.Insert.IntoTable("Tags").Row(new
{
- Label = "test"
+ Label = "test"
});
});
@@ -46,6 +56,14 @@ namespace NzbDrone.Core.Test.Datastore.Migration
{
var db = WithMigrationTestDb(c =>
{
+ c.Insert.IntoTable("Profiles").Row(new
+
+ {
+ Name = "Profile1",
+ CutOff = 0,
+ Items = "[]",
+ Language = 1
+ });
c.Insert.IntoTable("Series").Row(new
{
Tvdbid = 1,
@@ -61,7 +79,8 @@ namespace NzbDrone.Core.Test.Datastore.Migration
SeriesType = 0,
UseSceneNumbering = 0,
LastInfoSync = "2000-01-01 00:00:00",
- Tags = "[]"
+ Tags = "[]",
+ ProfileId = 1
});
c.Insert.IntoTable("Tags").Row(new
@@ -113,6 +132,14 @@ namespace NzbDrone.Core.Test.Datastore.Migration
{
var db = WithMigrationTestDb(c =>
{
+ c.Insert.IntoTable("Profiles").Row(new
+
+ {
+ Name = "Profile1",
+ CutOff = 0,
+ Items = "[]",
+ Language = 1
+ });
c.Insert.IntoTable("Series").Row(new
{
Tvdbid = 1,
@@ -128,7 +155,8 @@ namespace NzbDrone.Core.Test.Datastore.Migration
SeriesType = 0,
UseSceneNumbering = 0,
LastInfoSync = "2000-01-01 00:00:00",
- Tags = "[2]"
+ Tags = "[2]",
+ ProfileId = 1
});
c.Insert.IntoTable("Tags").Row(new
@@ -151,6 +179,15 @@ namespace NzbDrone.Core.Test.Datastore.Migration
{
var db = WithMigrationTestDb(c =>
{
+ c.Insert.IntoTable("Profiles").Row(new
+
+ {
+ Name = "Profile1",
+ CutOff = 0,
+ Items = "[]",
+ Language = 1
+ });
+
c.Insert.IntoTable("Series").Row(new
{
Tvdbid = 1,
@@ -166,7 +203,8 @@ namespace NzbDrone.Core.Test.Datastore.Migration
SeriesType = 0,
UseSceneNumbering = 0,
LastInfoSync = "2000-01-01 00:00:00",
- Tags = "[2]"
+ Tags = "[2]",
+ ProfileId = 1
});
c.Insert.IntoTable("Series").Row(new
@@ -184,7 +222,8 @@ namespace NzbDrone.Core.Test.Datastore.Migration
SeriesType = 0,
UseSceneNumbering = 0,
LastInfoSync = "2000-01-01 00:00:00",
- Tags = "[]"
+ Tags = "[]",
+ ProfileId = 1
});
c.Insert.IntoTable("Tags").Row(new
diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/108_fix_metadata_file_extensionsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/108_fix_metadata_file_extensionsFixture.cs
index 5a8a1fe02..200a43056 100644
--- a/src/NzbDrone.Core.Test/Datastore/Migration/108_fix_metadata_file_extensionsFixture.cs
+++ b/src/NzbDrone.Core.Test/Datastore/Migration/108_fix_metadata_file_extensionsFixture.cs
@@ -1,8 +1,9 @@
-using System.Linq;
+using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Parser;
+using NzbDrone.Core.Languages;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
@@ -45,7 +46,7 @@ namespace NzbDrone.Core.Test.Datastore.Migration
RelativePath = "Series.Title.S01E01.en.srt",
Added = "2016-05-30 20:23:02.3725923",
LastUpdated = "2016-05-30 20:23:02.3725923",
- Language = Language.English,
+ Language = Core.Languages.Language.English,
Extension = "en.srt"
});
});
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs
index ab91dba4a..81adab1aa 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs
@@ -1,50 +1,226 @@
-using FluentAssertions;
+using FluentAssertions;
using NUnit.Framework;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Test.Framework;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Test.Languages;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
[TestFixture]
- public class CutoffSpecificationFixture : CoreTest
+ public class CutoffSpecificationFixture : CoreTest
{
[Test]
public void should_return_true_if_current_album_is_less_than_cutoff()
{
- Subject.CutoffNotMet(new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() },
- new QualityModel(Quality.MP3_192, new Revision(version: 2))).Should().BeTrue();
+ Subject.CutoffNotMet(
+ new Profile
+
+ {
+ Cutoff = Quality.MP3_256,
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ },
+ new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(Language.English),
+ Cutoff = Language.English
+ },
+ new QualityModel(Quality.MP3_192, new Revision(version: 2)), Language.English).Should().BeTrue();
}
[Test]
public void should_return_false_if_current_album_is_equal_to_cutoff()
{
- Subject.CutoffNotMet(new Profile { Cutoff = Quality.MP3_256, Items = Qualities.QualityFixture.GetDefaultQualities() },
- new QualityModel(Quality.MP3_256, new Revision(version: 2))).Should().BeFalse();
+ Subject.CutoffNotMet(
+ new Profile
+ {
+ Cutoff = Quality.MP3_256,
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ },
+ new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(Language.English),
+ Cutoff = Language.English
+ },
+ new QualityModel(Quality.MP3_256, new Revision(version: 2)), Language.English).Should().BeFalse();
}
[Test]
public void should_return_false_if_current_album_is_greater_than_cutoff()
{
- Subject.CutoffNotMet(new Profile { Cutoff = Quality.MP3_256, Items = Qualities.QualityFixture.GetDefaultQualities() },
- new QualityModel(Quality.MP3_512, new Revision(version: 2))).Should().BeFalse();
+ Subject.CutoffNotMet(
+ new Profile
+
+ {
+ Cutoff = Quality.MP3_256,
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ },
+ new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(Language.English),
+ Cutoff = Language.English
+ },
+ new QualityModel(Quality.MP3_320, new Revision(version: 2)), Language.English).Should().BeFalse();
}
[Test]
public void should_return_true_when_new_album_is_proper_but_existing_is_not()
{
- Subject.CutoffNotMet(new Profile { Cutoff = Quality.MP3_256, Items = Qualities.QualityFixture.GetDefaultQualities() },
- new QualityModel(Quality.MP3_256, new Revision(version: 1)),
- new QualityModel(Quality.MP3_256, new Revision(version: 2))).Should().BeTrue();
+ Subject.CutoffNotMet(
+ new Profile
+
+ {
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ },
+ new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(Language.English),
+ Cutoff = Language.English
+ },
+ new QualityModel(Quality.MP3_320, new Revision(version: 1)),Language.English,
+ new QualityModel(Quality.MP3_320, new Revision(version: 2))).Should().BeTrue();
+
}
[Test]
public void should_return_false_if_cutoff_is_met_and_quality_is_higher()
{
- Subject.CutoffNotMet(new Profile { Cutoff = Quality.MP3_256, Items = Qualities.QualityFixture.GetDefaultQualities() },
- new QualityModel(Quality.MP3_256, new Revision(version: 2)),
- new QualityModel(Quality.MP3_512, new Revision(version: 2))).Should().BeFalse();
+ Subject.CutoffNotMet(
+ new Profile
+
+ {
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ },
+ new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(Language.English),
+ Cutoff = Language.English
+ },
+ new QualityModel(Quality.MP3_320, new Revision(version: 2)),Language.English,
+ new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse();
+ }
+
+ [Test]
+ public void should_return_true_if_quality_cutoff_is_met_and_quality_is_higher_but_language_is_not_met()
+ {
+
+ Profile _profile = new Profile
+ {
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ };
+
+ LanguageProfile _langProfile = new LanguageProfile
+ {
+ Cutoff = Language.Spanish,
+ Languages = LanguageFixture.GetDefaultLanguages()
+ };
+
+ Subject.CutoffNotMet(_profile,
+ _langProfile,
+ new QualityModel(Quality.MP3_320, new Revision(version: 2)),
+ Language.English,
+ new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeTrue();
+ }
+
+ [Test]
+ public void should_return_false_if_cutoff_is_met_and_quality_is_higher_and_language_is_met()
+ {
+
+ Profile _profile = new Profile
+ {
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ };
+
+ LanguageProfile _langProfile = new LanguageProfile
+ {
+ Cutoff = Language.Spanish,
+ Languages = LanguageFixture.GetDefaultLanguages()
+ };
+
+ Subject.CutoffNotMet(
+ _profile,
+ _langProfile,
+ new QualityModel(Quality.MP3_320, new Revision(version: 2)),
+ Language.Spanish,
+ new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse();
+ }
+
+ [Test]
+ public void should_return_false_if_cutoff_is_met_and_quality_is_higher_and_language_is_higher()
+ {
+
+ Profile _profile = new Profile
+ {
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ };
+
+ LanguageProfile _langProfile = new LanguageProfile
+ {
+ Cutoff = Language.Spanish,
+ Languages = LanguageFixture.GetDefaultLanguages()
+ };
+
+ Subject.CutoffNotMet(
+ _profile,
+ _langProfile,
+ new QualityModel(Quality.MP3_320, new Revision(version: 2)),
+ Language.French,
+ new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse();
+ }
+
+ [Test]
+ public void should_return_true_if_cutoff_is_not_met_and_new_quality_is_higher_and_language_is_higher()
+ {
+
+ Profile _profile = new Profile
+ {
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ };
+
+ LanguageProfile _langProfile = new LanguageProfile
+ {
+ Cutoff = Language.Spanish,
+ Languages = LanguageFixture.GetDefaultLanguages()
+ };
+
+ Subject.CutoffNotMet(
+ _profile,
+ _langProfile,
+ new QualityModel(Quality.MP3_256, new Revision(version: 2)),
+ Language.French,
+ new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeTrue();
+ }
+
+ [Test]
+ public void should_return_true_if_cutoff_is_not_met_and_language_is_higher()
+ {
+
+ Profile _profile = new Profile
+ {
+ Cutoff = Quality.MP3_320,
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ };
+
+ LanguageProfile _langProfile = new LanguageProfile
+ {
+ Cutoff = Language.Spanish,
+ Languages = LanguageFixture.GetDefaultLanguages()
+ };
+
+ Subject.CutoffNotMet(
+ _profile,
+ _langProfile,
+ new QualityModel(Quality.MP3_256, new Revision(version: 2)),
+ Language.French).Should().BeTrue();
}
}
}
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs
index df79537ae..18f92f69d 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs
@@ -9,11 +9,14 @@ using NzbDrone.Core.DecisionEngine.Specifications.RssSync;
using NzbDrone.Core.History;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Core.Profiles;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Music;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Test.Framework;
+using NzbDrone.Core.Profiles.Qualities;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Test.Languages;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
@@ -24,8 +27,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
private RemoteAlbum _parseResultMulti;
private RemoteAlbum _parseResultSingle;
- private QualityModel _upgradableQuality;
- private QualityModel _notupgradableQuality;
+ private Tuple _upgradableQuality;
+ private Tuple _notupgradableQuality;
private Artist _fakeArtist;
private const int FIRST_ALBUM_ID = 1;
private const int SECOND_ALBUM_ID = 2;
@@ -33,7 +36,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[SetUp]
public void Setup()
{
- Mocker.Resolve();
+ Mocker.Resolve();
_upgradeHistory = Mocker.Resolve();
var singleAlbumList = new List { new Album { Id = FIRST_ALBUM_ID} };
@@ -45,34 +48,35 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
_fakeArtist = Builder.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() })
+ .With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = LanguageFixture.GetDefaultLanguages() })
.Build();
_parseResultMulti = new RemoteAlbum
{
Artist = _fakeArtist,
- ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) },
+ ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)), Language = Language.English },
Albums = doubleAlbumList
};
_parseResultSingle = new RemoteAlbum
{
Artist = _fakeArtist,
- ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) },
+ ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)), Language = Language.English },
Albums = singleAlbumList
};
- _upgradableQuality = new QualityModel(Quality.MP3_192, new Revision(version: 1));
- _notupgradableQuality = new QualityModel(Quality.MP3_512, new Revision(version: 2));
+ _upgradableQuality = new Tuple(new QualityModel(Quality.MP3_192, new Revision(version: 1)), Language.English);
+ _notupgradableQuality = new Tuple(new QualityModel(Quality.MP3_512, new Revision(version: 2)), Language.English);
Mocker.GetMock()
.SetupGet(s => s.EnableCompletedDownloadHandling)
.Returns(true);
}
- private void GivenMostRecentForAlbum(int albumId, string downloadId, QualityModel quality, DateTime date, HistoryEventType eventType)
+ private void GivenMostRecentForAlbum(int albumId, string downloadId, Tuple quality, DateTime date, HistoryEventType eventType)
{
Mocker.GetMock().Setup(s => s.MostRecentForAlbum(albumId))
- .Returns(new History.History { DownloadId = downloadId, Quality = quality, Date = date, EventType = eventType });
+ .Returns(new History.History { DownloadId = downloadId, Quality = quality.Item1, Date = date, EventType = eventType, Language = quality.Item2 });
}
private void GivenCdhDisabled()
@@ -160,7 +164,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
_fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
- _upgradableQuality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
+ _upgradableQuality = new Tuple(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.English);
GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed);
@@ -172,7 +176,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
_fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
- _upgradableQuality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
+ _upgradableQuality = new Tuple(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.Spanish);
GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed);
@@ -200,7 +204,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
GivenCdhDisabled();
_fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
- _upgradableQuality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
+ _upgradableQuality = new Tuple(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.Spanish);
GivenMostRecentForAlbum(FIRST_ALBUM_ID, "test", _upgradableQuality, DateTime.UtcNow.AddDays(-100), HistoryEventType.Grabbed);
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/LanguageSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/LanguageSpecificationFixture.cs
index 5bb65c62e..d9b21e3f6 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/LanguageSpecificationFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/LanguageSpecificationFixture.cs
@@ -1,10 +1,11 @@
-using FluentAssertions;
+using FluentAssertions;
using Marr.Data;
using NUnit.Framework;
using NzbDrone.Core.DecisionEngine.Specifications;
-using NzbDrone.Core.Parser;
+using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Test.Languages;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Music;
@@ -19,19 +20,25 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[SetUp]
public void Setup()
{
+
+ LanguageProfile _profile = new LazyLoaded(new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(Language.English, Language.Spanish),
+ Cutoff = Language.Spanish
+ });
+
+
_remoteAlbum = new RemoteAlbum
{
ParsedAlbumInfo = new ParsedAlbumInfo
{
Language = Language.English
},
+
Artist = new Artist
- {
- Profile = new LazyLoaded(new Profile
- {
- Language = Language.English
- })
- }
+ {
+ LanguageProfile = _profile
+ }
};
}
@@ -42,7 +49,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
private void WithGermanRelease()
{
- _remoteAlbum.ParsedAlbumInfo.Language = Language.German;
+ _remoteAlbum.ParsedAlbumInfo.Language = Language.German;
}
[Test]
@@ -61,4 +68,4 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Mocker.Resolve().IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs
index 1f3d4d03d..722cc71e8 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs
@@ -1,11 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Moq;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Profiles.Delay;
using NzbDrone.Core.Music;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.DecisionEngine;
@@ -14,6 +14,9 @@ using FluentAssertions;
using FizzWare.NBuilder;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Test.Framework;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Test.Languages;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
@@ -33,11 +36,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.Build();
}
- private RemoteAlbum GivenRemoteAlbum(List albums, QualityModel quality, int age = 0, long size = 0, DownloadProtocol downloadProtocol = DownloadProtocol.Usenet)
+ private RemoteAlbum GivenRemoteAlbum(List albums, QualityModel quality, Language language, int age = 0, long size = 0, DownloadProtocol downloadProtocol = DownloadProtocol.Usenet)
{
var remoteAlbum = new RemoteAlbum();
remoteAlbum.ParsedAlbumInfo = new ParsedAlbumInfo();
remoteAlbum.ParsedAlbumInfo.Quality = quality;
+ remoteAlbum.ParsedAlbumInfo.Language = language;
remoteAlbum.Albums = new List();
remoteAlbum.Albums.AddRange(albums);
@@ -48,8 +52,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
remoteAlbum.Release.DownloadProtocol = downloadProtocol;
remoteAlbum.Artist = Builder.CreateNew()
- .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() })
- .Build();
+ .With(e => e.Profile = new Profile
+ {
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ })
+ .With(l => l.LanguageProfile = new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(),
+ Cutoff = Language.Spanish
+ }).Build();
return remoteAlbum;
}
@@ -67,8 +78,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_put_propers_before_non_propers()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256, new Revision(version: 1)));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256, new Revision(version: 2)));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256, new Revision(version: 1)), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256, new Revision(version: 2)), Language.English);
var decisions = new List();
decisions.Add(new DownloadDecision(remoteAlbum1));
@@ -81,8 +92,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_put_higher_quality_before_lower()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
var decisions = new List();
decisions.Add(new DownloadDecision(remoteAlbum1));
@@ -95,10 +106,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_order_by_age_then_largest_rounded_to_200mb()
{
- var remoteAlbumSd = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192), size: 100.Megabytes(), age: 1);
- var remoteAlbumHdSmallOld = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 1200.Megabytes(), age: 1000);
- var remoteAlbumSmallYoung = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 1250.Megabytes(), age: 10);
- var remoteAlbumHdLargeYoung = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 3000.Megabytes(), age: 1);
+ var remoteAlbumSd = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192), Language.English, size: 100.Megabytes(), age: 1);
+ var remoteAlbumHdSmallOld = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, size: 1200.Megabytes(), age: 1000);
+ var remoteAlbumSmallYoung = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, size: 1250.Megabytes(), age: 10);
+ var remoteAlbumHdLargeYoung = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, size: 3000.Megabytes(), age: 1);
var decisions = new List();
decisions.Add(new DownloadDecision(remoteAlbumSd));
@@ -113,8 +124,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_order_by_youngest()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), age: 10);
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), age: 5);
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, age: 10);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, age: 5);
var decisions = new List();
@@ -128,8 +139,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_not_throw_if_no_episodes_are_found()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 500.Megabytes());
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 500.Megabytes());
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, size: 500.Megabytes());
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, size: 500.Megabytes());
remoteAlbum1.Albums = new List();
@@ -145,8 +156,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
GivenPreferredDownloadProtocol(DownloadProtocol.Usenet);
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Torrent);
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Usenet);
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, downloadProtocol: DownloadProtocol.Torrent);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, downloadProtocol: DownloadProtocol.Usenet);
var decisions = new List();
decisions.Add(new DownloadDecision(remoteAlbum1));
@@ -161,8 +172,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
GivenPreferredDownloadProtocol(DownloadProtocol.Torrent);
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Torrent);
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Usenet);
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, downloadProtocol: DownloadProtocol.Torrent);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English, downloadProtocol: DownloadProtocol.Usenet);
var decisions = new List();
decisions.Add(new DownloadDecision(remoteAlbum1));
@@ -175,8 +186,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_prefer_single_album_over_multi_album()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1), GivenAlbum(2) }, new QualityModel(Quality.MP3_256));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1), GivenAlbum(2) }, new QualityModel(Quality.MP3_256), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
var decisions = new List();
decisions.Add(new DownloadDecision(remoteAlbum1));
@@ -189,8 +200,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_prefer_releases_with_more_seeders()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
var torrentInfo1 = new TorrentInfo();
torrentInfo1.PublishDate = DateTime.Now;
@@ -209,14 +220,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
decisions.Add(new DownloadDecision(remoteAlbum2));
var qualifiedReports = Subject.PrioritizeDecisions(decisions);
- ((TorrentInfo) qualifiedReports.First().RemoteAlbum.Release).Seeders.Should().Be(torrentInfo2.Seeders);
+ ((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Seeders.Should().Be(torrentInfo2.Seeders);
}
[Test]
public void should_prefer_releases_with_more_peers_given_equal_number_of_seeds()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
var torrentInfo1 = new TorrentInfo();
torrentInfo1.PublishDate = DateTime.Now;
@@ -243,8 +254,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_prefer_releases_with_more_peers_no_seeds()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
var torrentInfo1 = new TorrentInfo();
torrentInfo1.PublishDate = DateTime.Now;
@@ -272,8 +283,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_prefer_first_release_if_peers_and_size_are_too_similar()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
var torrentInfo1 = new TorrentInfo();
torrentInfo1.PublishDate = DateTime.Now;
@@ -295,14 +306,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
decisions.Add(new DownloadDecision(remoteAlbum2));
var qualifiedReports = Subject.PrioritizeDecisions(decisions);
- ((TorrentInfo) qualifiedReports.First().RemoteAlbum.Release).Should().Be(torrentInfo1);
+ ((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Should().Be(torrentInfo1);
}
[Test]
public void should_prefer_first_release_if_age_and_size_are_too_similar()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.English);
remoteAlbum1.Release.PublishDate = DateTime.UtcNow.AddDays(-100);
remoteAlbum1.Release.Size = 200.Megabytes();
@@ -321,8 +332,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_prefer_quality_over_the_number_of_peers()
{
- var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_512));
- var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192));
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_512), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192), Language.English);
var torrentInfo1 = new TorrentInfo();
torrentInfo1.PublishDate = DateTime.Now;
@@ -346,5 +357,37 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var qualifiedReports = Subject.PrioritizeDecisions(decisions);
((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Should().Be(torrentInfo1);
}
+
+ [Test]
+ public void should_order_by_language()
+ {
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_320), Language.English);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_320), Language.French);
+ var remoteAlbum3 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_320), Language.German);
+
+
+ var decisions = new List();
+ decisions.Add(new DownloadDecision(remoteAlbum1));
+ decisions.Add(new DownloadDecision(remoteAlbum2));
+ decisions.Add(new DownloadDecision(remoteAlbum3));
+
+ var qualifiedReports = Subject.PrioritizeDecisions(decisions);
+ qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Language.Should().Be(Language.French);
+ qualifiedReports.Last().RemoteAlbum.ParsedAlbumInfo.Language.Should().Be(Language.German);
+ }
+
+ [Test]
+ public void should_put_higher_quality_before_lower_allways()
+ {
+ var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), Language.French);
+ var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_320), Language.German);
+
+ var decisions = new List();
+ decisions.Add(new DownloadDecision(remoteAlbum1));
+ decisions.Add(new DownloadDecision(remoteAlbum2));
+
+ var qualifiedReports = Subject.PrioritizeDecisions(decisions);
+ qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Quality.Should().Be(Quality.MP3_320);
+ }
}
}
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs
index d362c6d0e..2fc4cf993 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs
@@ -1,10 +1,10 @@
-using FizzWare.NBuilder;
+using FizzWare.NBuilder;
using FluentAssertions;
using Marr.Data;
using NUnit.Framework;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Music;
using NzbDrone.Core.Test.Framework;
@@ -63,4 +63,4 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsSatisfiedBy(remoteAlbum, null).Accepted.Should().BeFalse();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs
index a3791ba77..7461f4ca2 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs
@@ -1,16 +1,19 @@
-using FluentAssertions;
+using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Configuration;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Test.Framework;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Test.Languages;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
[TestFixture]
-
- public class QualityUpgradeSpecificationFixture : CoreTest
+
+ public class QualityUpgradeSpecificationFixture : CoreTest
{
public static object[] IsUpgradeTestCases =
{
@@ -22,7 +25,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new object[] { Quality.MP3_320, 1, Quality.MP3_320, 1, Quality.MP3_320, false },
new object[] { Quality.MP3_512, 1, Quality.MP3_512, 1, Quality.MP3_512, false }
};
-
+
+ public static object[] IsUpgradeTestCasesLanguages =
+ {
+ new object[] { Quality.MP3_192, 1, Language.English, Quality.MP3_192, 2, Language.English, Quality.MP3_192, Language.Spanish, true },
+ new object[] { Quality.MP3_192, 1, Language.English, Quality.MP3_192, 1, Language.Spanish, Quality.MP3_192, Language.Spanish, true },
+ new object[] { Quality.MP3_320, 1, Language.French, Quality.MP3_320, 2, Language.English, Quality.MP3_320, Language.Spanish, true },
+ new object[] { Quality.MP3_192, 1, Language.English, Quality.MP3_192, 1, Language.English, Quality.MP3_192, Language.English, false },
+ new object[] { Quality.MP3_320, 1, Language.English, Quality.MP3_320, 2, Language.Spanish, Quality.FLAC, Language.Spanish, false },
+ new object[] { Quality.MP3_320, 1, Language.Spanish, Quality.MP3_320, 2, Language.French, Quality.MP3_320, Language.Spanish, false }
+ };
+
[SetUp]
public void Setup()
{
@@ -41,10 +54,41 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
GivenAutoDownloadPropers(true);
- var profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() };
+ var profile = new Profile
- Subject.IsUpgradable(profile, new QualityModel(current, new Revision(version: currentVersion)), new QualityModel(newQuality, new Revision(version: newVersion)))
- .Should().Be(expected);
+ {
+ Items = Qualities.QualityFixture.GetDefaultQualities()
+ };
+
+ var langProfile = new LanguageProfile
+
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(),
+ Cutoff = Language.English
+ };
+
+ Subject.IsUpgradable(profile, langProfile, new QualityModel(current, new Revision(version: currentVersion)), Language.English, new QualityModel(newQuality, new Revision(version: newVersion)), Language.English)
+ .Should().Be(expected);
+ }
+
+ [Test, TestCaseSource("IsUpgradeTestCasesLanguages")]
+ public void IsUpgradeTestLanguage(Quality current, int currentVersion, Language currentLanguage, Quality newQuality, int newVersion, Language newLanguage, Quality cutoff, Language languageCutoff, bool expected)
+ {
+ GivenAutoDownloadPropers(true);
+
+ var profile = new Profile
+ {
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ Cutoff = cutoff,
+ };
+
+ var langProfile = new LanguageProfile
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(),
+ Cutoff = languageCutoff
+ };
+
+ Subject.IsUpgradable(profile, langProfile, new QualityModel(current, new Revision(version: currentVersion)), currentLanguage, new QualityModel(newQuality, new Revision(version: newVersion)), newLanguage).Should().Be(expected);
}
[Test]
@@ -52,10 +96,20 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
GivenAutoDownloadPropers(false);
- var profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() };
+ var profile = new Profile
+ {
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ };
+
+ var langProfile = new LanguageProfile
+
+ {
+ Languages = LanguageFixture.GetDefaultLanguages(),
+ Cutoff = Language.English
+ };
- Subject.IsUpgradable(profile, new QualityModel(Quality.MP3_192, new Revision(version: 2)), new QualityModel(Quality.MP3_192, new Revision(version: 1)))
- .Should().BeFalse();
+ Subject.IsUpgradable(profile, langProfile, new QualityModel(Quality.MP3_256, new Revision(version: 2)), Language.English, new QualityModel(Quality.MP3_256, new Revision(version: 1)), Language.English)
+ .Should().BeFalse();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs
index 38dc8d420..2e51b0989 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
@@ -6,11 +6,13 @@ using NUnit.Framework;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Queue;
using NzbDrone.Core.Music;
using NzbDrone.Core.Test.Framework;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Languages;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
@@ -27,11 +29,18 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[SetUp]
public void Setup()
{
- Mocker.Resolve();
+ Mocker.Resolve();
_artist = Builder.CreateNew()
- .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() })
- .Build();
+ .With(e => e.Profile = new Profile
+ {
+ Items = Qualities.QualityFixture.GetDefaultQualities(),
+ })
+ .With(l => l.LanguageProfile = new LanguageProfile
+ {
+ Languages = Languages.LanguageFixture.GetDefaultLanguages(),
+ Cutoff = Language.Spanish
+ }).Build();
_album = Builder.CreateNew()
.With(e => e.ArtistId = _artist.Id)
@@ -49,7 +58,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
_remoteAlbum = Builder.CreateNew()
.With(r => r.Artist = _artist)
.With(r => r.Albums = new List { _album })
- .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192) })
+ .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256), Language = Language.Spanish })
.Build();
}
@@ -95,14 +104,36 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void should_return_true_when_quality_in_queue_is_lower()
{
_artist.Profile.Value.Cutoff = Quality.MP3_512;
+ _artist.LanguageProfile.Value.Cutoff = Language.Spanish;
var remoteAlbum = Builder.CreateNew()
.With(r => r.Artist = _artist)
.With(r => r.Albums = new List { _album })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
- {
- Quality = new QualityModel(Quality.MP3_192)
- })
+ {
+ Quality = new QualityModel(Quality.MP3_192),
+ Language = Language.Spanish
+ })
+ .Build();
+
+ GivenQueue(new List { remoteAlbum });
+ Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue();
+ }
+
+ [Test]
+ public void should_return_true_when_quality_in_queue_is_lower_but_language_is_higher()
+ {
+ _artist.Profile.Value.Cutoff = Quality.FLAC;
+ _artist.LanguageProfile.Value.Cutoff = Language.Spanish;
+
+ var remoteAlbum = Builder.CreateNew()
+ .With(r => r.Artist = _artist)
+ .With(r => r.Albums = new List { _album })
+ .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
+ {
+ Quality = new QualityModel(Quality.MP3_192),
+ Language = Language.English
+ })
.Build();
GivenQueue(new List { remoteAlbum });
@@ -116,9 +147,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Artist = _artist)
.With(r => r.Albums = new List { _otherAlbum })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
- {
- Quality = new QualityModel(Quality.MP3_192)
- })
+ {
+ Quality = new QualityModel(Quality.MP3_192)
+ })
.Build();
GivenQueue(new List { remoteAlbum });
@@ -126,21 +157,39 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
}
[Test]
- public void should_return_false_when_qualities_are_the_same()
+ public void should_return_false_when_qualities_are_the_same_and_languages_are_the_same()
{
var remoteAlbum = Builder.CreateNew()
.With(r => r.Artist = _artist)
.With(r => r.Albums = new List { _album })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
- {
- Quality = new QualityModel(Quality.MP3_192)
- })
+ {
+ Quality = new QualityModel(Quality.MP3_192),
+ Language = Language.Spanish
+ })
.Build();
GivenQueue(new List { remoteAlbum });
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse();
}
+ [Test]
+ public void should_return_true_when_qualities_are_the_same_but_language_is_better()
+ {
+ var remoteAlbum = Builder.CreateNew()
+ .With(r => r.Artist = _artist)
+ .With(r => r.Albums = new List { _album })
+ .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
+ {
+ Quality = new QualityModel(Quality.MP3_192),
+ Language = Language.English,
+ })
+ .Build();
+
+ GivenQueue(new List { remoteAlbum });
+ Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue();
+ }
+
[Test]
public void should_return_false_when_quality_in_queue_is_better()
{
@@ -150,9 +199,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Artist = _artist)
.With(r => r.Albums = new List { _album })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
- {
- Quality = new QualityModel(Quality.MP3_256)
- })
+ {
+ Quality = new QualityModel(Quality.MP3_256),
+ Language = Language.English
+ })
.Build();
GivenQueue(new List { remoteAlbum });
@@ -167,7 +217,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Albums = new List { _album, _otherAlbum })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
{
- Quality = new QualityModel(Quality.MP3_256)
+ Quality = new QualityModel(Quality.MP3_256),
+ Language = Language.English
})
.Build();
@@ -183,7 +234,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Albums = new List { _album })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
{
- Quality = new QualityModel(Quality.MP3_256)
+ Quality = new QualityModel(Quality.MP3_256),
+ Language = Language.English
})
.Build();
@@ -201,7 +253,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Albums = new List { _album, _otherAlbum })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
{
- Quality = new QualityModel(Quality.MP3_256)
+ Quality = new QualityModel(Quality.MP3_256),
+ Language = Language.English
})
.Build();
@@ -218,11 +271,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.All()
.With(r => r.Artist = _artist)
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
- {
- Quality =
- new QualityModel(
- Quality.MP3_256)
- })
+ {
+ Quality = new QualityModel(Quality.MP3_256),
+ Language = Language.English
+ })
.TheFirst(1)
.With(r => r.Albums = new List { _album })
.TheNext(1)
@@ -235,7 +287,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
}
[Test]
- public void should_return_false_if_quality_in_queue_meets_cutoff()
+ public void should_return_false_if_quality_and_language_in_queue_meets_cutoff()
{
_artist.Profile.Value.Cutoff = _remoteAlbum.ParsedAlbumInfo.Quality.Quality;
@@ -244,7 +296,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Albums = new List { _album })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
{
- Quality = new QualityModel(Quality.MP3_256)
+ Quality = new QualityModel(Quality.MP3_256),
+ Language = Language.Spanish
})
.Build();
@@ -253,4 +306,4 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs
index 9416dc0c7..35c174ccf 100644
--- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
@@ -13,7 +13,9 @@ using NzbDrone.Core.Indexers;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
+using NzbDrone.Core.Profiles.Languages;
+using NzbDrone.Core.Languages;
using NzbDrone.Core.Profiles.Delay;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
@@ -25,6 +27,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
public class DelaySpecificationFixture : CoreTest
{
private Profile _profile;
+ private LanguageProfile _langProfile;
private DelayProfile _delayProfile;
private RemoteAlbum _remoteAlbum;
@@ -34,12 +37,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_profile = Builder.CreateNew()
.Build();
+ _langProfile = Builder.CreateNew()
+ .Build();
+
+
_delayProfile = Builder.CreateNew()
- .With(d => d.PreferredProtocol = DownloadProtocol.Usenet)
- .Build();
+ .With(d => d.PreferredProtocol = DownloadProtocol.Usenet)
+ .Build();
var artist = Builder.CreateNew()
.With(s => s.Profile = _profile)
+ .With(s => s.LanguageProfile = _langProfile)
.Build();
_remoteAlbum = Builder.CreateNew()
@@ -53,6 +61,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_profile.Cutoff = Quality.MP3_320;
+ _langProfile.Cutoff = Language.Spanish;
+ _langProfile.Languages = Languages.LanguageFixture.GetDefaultLanguages();
+
_remoteAlbum.ParsedAlbumInfo = new ParsedAlbumInfo();
_remoteAlbum.Release = new ReleaseInfo();
_remoteAlbum.Release.DownloadProtocol = DownloadProtocol.Usenet;
@@ -61,7 +72,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
Mocker.GetMock()
.Setup(s => s.GetFilesByAlbum(It.IsAny(), It.IsAny()))
- .Returns(new List {});
+ .Returns(new List { });
Mocker.GetMock()
.Setup(s => s.BestForTags(It.IsAny>()))
@@ -72,17 +83,20 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
.Returns(new List());
}
- private void GivenExistingFile(QualityModel quality)
+ private void GivenExistingFile(QualityModel quality, Language language)
{
Mocker.GetMock()
.Setup(s => s.GetFilesByAlbum(It.IsAny(), It.IsAny()))
- .Returns(new List { new TrackFile { Quality = quality } });
+ .Returns(new List { new TrackFile {
+ Quality = quality,
+ Language = language
+ } });
}
private void GivenUpgradeForExistingFile()
{
- Mocker.GetMock()
- .Setup(s => s.IsUpgradable(It.IsAny(), It.IsAny(), It.IsAny()))
+ Mocker.GetMock()
+ .Setup(s => s.IsUpgradable(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(true);
}
@@ -112,9 +126,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
}
[Test]
- public void should_be_true_when_quality_is_last_allowed_in_profile()
+ public void should_be_true_when_quality_and_language_is_last_allowed_in_profile()
{
_remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320);
+ _remoteAlbum.ParsedAlbumInfo.Language = Language.French;
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue();
}
@@ -147,10 +162,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2));
_remoteAlbum.Release.PublishDate = DateTime.UtcNow;
- GivenExistingFile(new QualityModel(Quality.MP3_256));
+ GivenExistingFile(new QualityModel(Quality.MP3_256), Language.English);
GivenUpgradeForExistingFile();
- Mocker.GetMock()
+ Mocker.GetMock()
.Setup(s => s.IsRevisionUpgrade(It.IsAny(), It.IsAny()))
.Returns(true);
@@ -165,10 +180,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256, new Revision(real: 1));
_remoteAlbum.Release.PublishDate = DateTime.UtcNow;
- GivenExistingFile(new QualityModel(Quality.MP3_256));
+ GivenExistingFile(new QualityModel(Quality.MP3_256), Language.English);
GivenUpgradeForExistingFile();
- Mocker.GetMock()
+ Mocker.GetMock