diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json
new file mode 100644
index 000000000..a82e49732
--- /dev/null
+++ b/frontend/.csscomb.json
@@ -0,0 +1,25 @@
+{
+ "remove-empty-rulesets": true,
+ "always-semicolon": true,
+ "color-case": "lower",
+ "block-indent": " ",
+ "color-shorthand": false,
+ "element-case": "lower",
+ "eof-newline": true,
+ "leading-zero": true,
+ "quotes": "double",
+ "sort-order-fallback": "abc",
+ "space-before-colon": "",
+ "space-after-colon": " ",
+ "space-before-combinator": " ",
+ "space-after-combinator": " ",
+ "space-between-declarations": "\n",
+ "space-before-opening-brace": " ",
+ "space-after-opening-brace": "\n",
+ "space-after-selector-delimiter": " ",
+ "space-before-selector-delimiter": "",
+ "space-before-closing-brace": "\n",
+ "strip-spaces": true,
+ "tab-size": true,
+ "unitless-zero": false
+}
diff --git a/frontend/.esformatter b/frontend/.esformatter
new file mode 100644
index 000000000..600bb0751
--- /dev/null
+++ b/frontend/.esformatter
@@ -0,0 +1,335 @@
+{
+ "indent": {
+ "value": " ",
+ "FunctionExpression": 1,
+ "ArrayExpression": 1,
+ "ObjectExpression": 1
+ },
+ "lineBreak": {
+ "value": "\n",
+
+ "before": {
+ "ArrayPatternClosing": 0,
+ "ArrayPatternComma": 0,
+ "ArrayPatternOpening": 0,
+ "ArrowFunctionExpressionArrow": 0,
+ "ArrowFunctionExpressionClosingBrace": ">=1",
+ "ArrowFunctionExpressionOpeningBrace": 0,
+ "AssignmentExpression": ">=1",
+ "AssignmentOperator": 0,
+ "BlockStatement": 0,
+ "BreakKeyword": ">=1",
+ "CallExpression": -1,
+ "CallExpressionClosingParentheses": -1,
+ "CallExpressionOpeningParentheses": 0,
+ "CatchClosingBrace": ">=1",
+ "CatchKeyword": 0,
+ "CatchOpeningBrace": 0,
+ "ClassDeclaration": ">=1",
+ "ClassDeclarationClosingBrace": ">=1",
+ "ClassDeclarationOpeningBrace": 0,
+ "ConditionalExpression": ">=1",
+ "DeleteOperator": ">=1",
+ "DoWhileStatement": ">=1",
+ "DoWhileStatementClosingBrace": ">=1",
+ "DoWhileStatementOpeningBrace": 0,
+ "ElseIfStatement": 0,
+ "ElseIfStatementClosingBrace": ">=1",
+ "ElseIfStatementOpeningBrace": 0,
+ "ElseStatement": 0,
+ "ElseStatementClosingBrace": ">=1",
+ "ElseStatementOpeningBrace": 0,
+ "EmptyStatement": -1,
+ "EndOfFile": -1,
+ "FinallyClosingBrace": ">=1",
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": 0,
+ "ForInStatement": ">=1",
+ "ForInStatementClosingBrace": ">=1",
+ "ForInStatementExpressionClosing": 0,
+ "ForInStatementExpressionOpening": 0,
+ "ForInStatementOpeningBrace": 0,
+ "ForStatement": ">=1",
+ "ForStatementClosingBrace": ">=1",
+ "ForStatementExpressionClosing": "<2",
+ "ForStatementExpressionOpening": 0,
+ "ForStatementOpeningBrace": 0,
+ "FunctionDeclaration": ">=1",
+ "FunctionDeclarationClosingBrace": ">=1",
+ "FunctionDeclarationOpeningBrace": 0,
+ "FunctionExpression": 0,
+ "FunctionExpressionClosingBrace": 1,
+ "FunctionExpressionOpeningBrace":0,
+ "IIFEClosingParentheses": 0,
+ "IfStatement": ">=1",
+ "IfStatementClosingBrace": ">=1",
+ "IfStatementOpeningBrace": 0,
+ "LogicalExpression": -1,
+ "MemberExpressionClosing": 0,
+ "MemberExpressionOpening": 0,
+ "MemberExpressionPeriod": -1,
+ "MethodDefinition": ">=1",
+ "ObjectExpressionClosingBrace": "<=1",
+ "ObjectPatternClosingBrace": 0,
+ "ObjectPatternComma": 0,
+ "ObjectPatternOpeningBrace": 0,
+ "ParameterDefault": 0,
+ "Property": "<=2",
+ "PropertyValue": 0,
+ "ReturnStatement": -1,
+ "SwitchClosingBrace": ">=1",
+ "SwitchOpeningBrace": 0,
+ "ThisExpression": -1,
+ "ThrowStatement": ">=1",
+ "TryClosingBrace": ">=1",
+ "TryKeyword": -1,
+ "TryOpeningBrace": 0,
+ "VariableDeclaration": ">=1",
+ "VariableDeclarationSemiColon": 0,
+ "VariableDeclarationWithoutInit": ">=1",
+ "VariableName": ">=1",
+ "VariableValue": 0,
+ "WhileStatement": ">=1",
+ "WhileStatementClosingBrace": ">=1",
+ "WhileStatementOpeningBrace": 0
+ },
+
+ "after": {
+ "ArrayPatternClosing": 0,
+ "ArrayPatternComma": 0,
+ "ArrayPatternOpening": 0,
+ "ArrowFunctionExpressionArrow": 0,
+ "ArrowFunctionExpressionClosingBrace": -1,
+ "ArrowFunctionExpressionOpeningBrace": ">=1",
+ "AssignmentExpression": ">=1",
+ "AssignmentOperator": 0,
+ "BlockStatement": 0,
+ "BreakKeyword": -1,
+ "CallExpression": -1,
+ "CallExpressionClosingParentheses": -1,
+ "CallExpressionOpeningParentheses": -1,
+ "CatchClosingBrace": ">=0",
+ "CatchKeyword": 0,
+ "CatchOpeningBrace": ">=1",
+ "ClassDeclaration": ">=1",
+ "ClassDeclarationClosingBrace": ">=1",
+ "ClassDeclarationOpeningBrace": ">=1",
+ "ConditionalExpression": ">=1",
+ "DeleteOperator": ">=1",
+ "DoWhileStatement": ">=1",
+ "DoWhileStatementClosingBrace": 0,
+ "DoWhileStatementOpeningBrace": ">=1",
+ "ElseIfStatement": ">=1",
+ "ElseIfStatementClosingBrace": ">=1",
+ "ElseIfStatementOpeningBrace": ">=1",
+ "ElseStatement": ">=1",
+ "ElseStatementClosingBrace": ">=1",
+ "ElseStatementOpeningBrace": ">=1",
+ "EmptyStatement": -1,
+ "FinallyClosingBrace": ">=1",
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": ">=1",
+ "ForInStatement": ">=1",
+ "ForInStatementClosingBrace": ">=1",
+ "ForInStatementExpressionClosing": -1,
+ "ForInStatementExpressionOpening": "<2",
+ "ForInStatementOpeningBrace": ">=1",
+ "ForStatement": ">=1",
+ "ForStatementClosingBrace": ">=1",
+ "ForStatementExpressionClosing": -1,
+ "ForStatementExpressionOpening": "<2",
+ "ForStatementOpeningBrace": ">=1",
+ "FunctionDeclaration": ">=1",
+ "FunctionDeclarationClosingBrace": ">=1",
+ "FunctionDeclarationOpeningBrace": ">=1",
+ "FunctionExpression": 0,
+ "FunctionExpressionClosingBrace": -1,
+ "FunctionExpressionOpeningBrace": 1,
+ "IIFEOpeningParentheses": 0,
+ "IfStatement": ">=1",
+ "IfStatementClosingBrace": ">=1",
+ "IfStatementOpeningBrace": ">=1",
+ "LogicalExpression": -1,
+ "MemberExpressionClosing": 0,
+ "MemberExpressionOpening": 0,
+ "MemberExpressionPeriod": 0,
+ "MethodDefinition": ">=1",
+ "ObjectExpressionOpeningBrace": "<=1",
+ "ObjectPatternClosingBrace": 0,
+ "ObjectPatternComma": 0,
+ "ObjectPatternOpeningBrace": 0,
+ "ParameterDefault": 0,
+ "Property": -1,
+ "PropertyName": 0,
+ "ReturnStatement": -1,
+ "SwitchCaseColon": ">=1",
+ "SwitchClosingBrace": ">=1",
+ "SwitchOpeningBrace": ">=1",
+ "ThisExpression": 0,
+ "ThrowStatement": ">=1",
+ "TryClosingBrace": 0,
+ "TryKeyword": -1,
+ "TryOpeningBrace": ">=1",
+ "VariableDeclaration": ">=1",
+ "VariableDeclarationSemiColon": ">=1",
+ "VariableValue": -1,
+ "WhileStatement": ">=1",
+ "WhileStatementClosingBrace": ">=1",
+ "WhileStatementOpeningBrace": ">=1"
+ }
+ },
+ "whiteSpace": {
+ "value": " ",
+ "removeTrailing": 1,
+ "before": {
+ "ArgumentComma": 0,
+ "ArgumentList": 0,
+ "ArgumentListArrayExpression": 0,
+ "ArgumentListFunctionExpression": 1,
+ "ArgumentListObjectExpression": 0,
+ "ArrayExpressionClosing": 0,
+ "ArrayExpressionComma": 0,
+ "ArrayExpressionOpening": 1,
+ "AssignmentOperator": 1,
+ "BinaryExpression": 0,
+ "BinaryExpressionOperator": 1,
+ "BlockComment": 1,
+ "CallExpression": 1,
+ "CatchClosingBrace": 1,
+ "CatchKeyword": 1,
+ "CatchOpeningBrace": 1,
+ "CatchParameterList": 0,
+ "CommaOperator": 0,
+ "ConditionalExpressionAlternate": 1,
+ "ConditionalExpressionConsequent": 1,
+ "DoWhileStatementClosingBrace": 1,
+ "DoWhileStatementConditional": 1,
+ "DoWhileStatementOpeningBrace": 1,
+ "ElseIfStatementClosingBrace": 1,
+ "ElseIfStatementOpeningBrace": 1,
+ "ElseStatementClosingBrace": 1,
+ "ElseStatementOpeningBrace": 1,
+ "EmptyStatement": 0,
+ "ExpressionClosingParentheses": 0,
+ "FinallyClosingBrace": 1,
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": 1,
+ "ForInStatement": 1,
+ "ForInStatementClosingBrace": 1,
+ "ForInStatementExpressionClosing": 0,
+ "ForInStatementExpressionOpening": 1,
+ "ForInStatementOpeningBrace": 1,
+ "ForStatement": 1,
+ "ForStatementClosingBrace": 1,
+ "ForStatementExpressionClosing": 0,
+ "ForStatementExpressionOpening": 1,
+ "ForStatementOpeningBrace": 1,
+ "ForStatementSemicolon": 0,
+ "FunctionDeclarationClosingBrace": 1,
+ "FunctionDeclarationOpeningBrace": 1,
+ "FunctionExpressionClosingBrace": 1,
+ "FunctionExpressionOpeningBrace": 1,
+ "IfStatementClosingBrace": 1,
+ "IfStatementConditionalClosing": 0,
+ "IfStatementConditionalOpening": 1,
+ "IfStatementOpeningBrace": 1,
+ "LineComment": 1,
+ "LogicalExpressionOperator": 1,
+ "MemberExpressionClosing": 0,
+ "ObjectExpressionClosingBrace": 1,
+ "ParameterComma": 0,
+ "ParameterList": 0,
+ "Property": 1,
+ "PropertyName": 1,
+ "PropertyValue": 1,
+ "SwitchDiscriminantClosing": 0,
+ "SwitchDiscriminantOpening": 1,
+ "ThrowKeyword": 1,
+ "TryClosingBrace": 1,
+ "TryKeyword": -1,
+ "TryOpeningBrace": 1,
+ "UnaryExpressionOperator": 0,
+ "VariableName": 1,
+ "VariableValue": 1,
+ "WhileStatementClosingBrace": 1,
+ "WhileStatementConditionalClosing": 0,
+ "WhileStatementConditionalOpening": 1,
+ "WhileStatementOpeningBrace": 1
+ },
+ "after": {
+ "ArgumentComma": 1,
+ "ArgumentList": 0,
+ "ArgumentListArrayExpression": 1,
+ "ArgumentListFunctionExpression": 1,
+ "ArgumentListObjectExpression": 0,
+ "ArrayExpressionClosing": 0,
+ "ArrayExpressionComma": 1,
+ "ArrayExpressionOpening": 0,
+ "AssignmentOperator": 1,
+ "BinaryExpression": 0,
+ "BinaryExpressionOperator": 1,
+ "BlockComment": 1,
+ "CallExpression": 0,
+ "CatchClosingBrace": 1,
+ "CatchKeyword": 1,
+ "CatchOpeningBrace": 1,
+ "CatchParameterList": 0,
+ "CommaOperator": 1,
+ "ConditionalExpressionConsequent": 1,
+ "ConditionalExpressionTest": 1,
+ "DoWhileStatementBody": 1,
+ "DoWhileStatementClosingBrace": 1,
+ "DoWhileStatementOpeningBrace": 1,
+ "ElseIfStatementClosingBrace": 1,
+ "ElseIfStatementOpeningBrace": 1,
+ "ElseStatementClosingBrace": 1,
+ "ElseStatementOpeningBrace": 1,
+ "EmptyStatement": 0,
+ "ExpressionOpeningParentheses": 0,
+ "FinallyClosingBrace": 1,
+ "FinallyKeyword": -1,
+ "FinallyOpeningBrace": 1,
+ "ForInStatement": 1,
+ "ForInStatementClosingBrace": 1,
+ "ForInStatementExpressionClosing": 1,
+ "ForInStatementExpressionOpening": 0,
+ "ForInStatementOpeningBrace": 1,
+ "ForStatement": 1,
+ "ForStatementClosingBrace": 1,
+ "ForStatementExpressionClosing": 1,
+ "ForStatementExpressionOpening": 0,
+ "ForStatementOpeningBrace": 1,
+ "ForStatementSemicolon": 1,
+ "FunctionDeclarationClosingBrace": 0,
+ "FunctionDeclarationOpeningBrace": 0,
+ "FunctionExpressionClosingBrace": 0,
+ "FunctionExpressionOpeningBrace": 0,
+ "FunctionName": 0,
+ "FunctionReservedWord": 0,
+ "IfStatementClosingBrace": 1,
+ "IfStatementConditionalClosing": 0,
+ "IfStatementConditionalOpening": 0,
+ "IfStatementOpeningBrace": 1,
+ "LogicalExpressionOperator": 1,
+ "MemberExpressionOpening": 0,
+ "ObjectExpressionClosingBrace": 0,
+ "ObjectExpressionOpeningBrace": 1,
+ "ParameterComma": 1,
+ "ParameterList": 0,
+ "PropertyName": 0,
+ "PropertyValue": 0,
+ "SwitchDiscriminantClosing": 1,
+ "SwitchDiscriminantOpening": 0,
+ "ThrowKeyword": 1,
+ "TryClosingBrace": 1,
+ "TryKeyword": -1,
+ "TryOpeningBrace": 1,
+ "UnaryExpressionOperator": 0,
+ "VariableName": 1,
+ "WhileStatementClosingBrace": 1,
+ "WhileStatementConditionalClosing": 1,
+ "WhileStatementConditionalOpening": 0,
+ "WhileStatementOpeningBrace": 1
+ }
+ }
+}
diff --git a/frontend/.eslintignore b/frontend/.eslintignore
new file mode 100644
index 000000000..d4b43f836
--- /dev/null
+++ b/frontend/.eslintignore
@@ -0,0 +1 @@
+**/JsLibraries/**
diff --git a/frontend/.eslintrc b/frontend/.eslintrc
new file mode 100644
index 000000000..31b1173ec
--- /dev/null
+++ b/frontend/.eslintrc
@@ -0,0 +1,288 @@
+{
+ "parser": "babel-eslint",
+
+ "env": {
+ "browser": true,
+ "commonjs": true,
+ "node": true,
+ "es6": true
+ },
+
+ "globals": {
+ "expect": false,
+ "chai": false,
+ "sinon": false
+ },
+
+ "parserOptions": {
+ "ecmaVersion": 6,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "modules": true,
+ "impliedStrict": true
+ }
+ },
+
+ "plugins": [
+ "filenames",
+ "react"
+ ],
+
+ "rules": {
+ "filenames/match-exported": ["error"],
+
+ # ECMAScript 6
+
+ "arrow-body-style": [0],
+ "arrow-parens": ["error", "always"],
+ "arrow-spacing": ["error", { "before": true, "after": true }],
+ "constructor-super": "error",
+ "generator-star-spacing": "off",
+ "no-class-assign": "error",
+ "no-confusing-arrow": "error",
+ "no-const-assign": "error",
+ "no-dupe-class-members": "error",
+ "no-duplicate-imports": "error",
+ "no-new-symbol": "error",
+ "no-this-before-super": "error",
+ "no-useless-escape": "error",
+ "no-useless-computed-key": "error",
+ "no-useless-constructor": "error",
+ "no-var": "warn",
+ "object-shorthand": ["error", "properties"],
+ "prefer-arrow-callback": "error",
+ "prefer-const": "warn",
+ "prefer-reflect": "off",
+ "prefer-rest-params": "off",
+ "prefer-spread": "warn",
+ "prefer-template": "error",
+ "require-yield": "off",
+ "template-curly-spacing": ["error", "never"],
+ "yield-star-spacing": "off",
+
+ # Possible Errors
+
+ "comma-dangle": "error",
+ "no-cond-assign": "error",
+ "no-console": "off",
+ "no-constant-condition": "warn",
+ "no-control-regex": "error",
+ "no-debugger": "off",
+ "no-dupe-args": "error",
+ "no-dupe-keys": "error",
+ "no-duplicate-case": "error",
+ "no-empty": "warn",
+ "no-empty-character-class": "error",
+ "no-ex-assign": "error",
+ "no-extra-boolean-cast": "error",
+ "no-extra-parens": ["error", "functions"],
+ "no-extra-semi": "error",
+ "no-func-assign": "error",
+ "no-inner-declarations": "error",
+ "no-invalid-regexp": "error",
+ "no-irregular-whitespace": "error",
+ "no-negated-in-lhs": "error",
+ "no-obj-calls": "error",
+ "no-regex-spaces": "error",
+ "no-sparse-arrays": "error",
+ "no-unexpected-multiline": "error",
+ "no-unreachable": "warn",
+ "no-unsafe-finally": "error",
+ "use-isnan": "error",
+ "valid-jsdoc": "off",
+ "valid-typeof": "error",
+
+ # Best Practices
+
+ "accessor-pairs": "off",
+ "array-callback-return": "warn",
+ "block-scoped-var": "warn",
+ "consistent-return": "off",
+ "curly": "error",
+ "default-case": "error",
+ "dot-location": ["error", "property"],
+ "dot-notation": "error",
+ "eqeqeq": ["error", "smart"],
+ "guard-for-in": "error",
+ "no-alert": "warn",
+ "no-caller": "error",
+ "no-case-declarations": "error",
+ "no-div-regex": "error",
+ "no-else-return": "error",
+ "no-empty-function": ["error", {"allow": ["arrowFunctions"]}],
+ "no-empty-pattern": "error",
+ "no-eval": "error",
+ "no-extend-native": "error",
+ "no-extra-bind": "error",
+ "no-fallthrough": "error",
+ "no-floating-decimal": "error",
+ "no-implicit-coercion": ["error", {
+ "boolean": false,
+ "number": true,
+ "string": true,
+ "allow": [/* "!!", "~", "*", "+" */]
+ }],
+ "no-implicit-globals": "error",
+ "no-implied-eval": "error",
+ "no-invalid-this": "off",
+ "no-iterator": "error",
+ "no-labels": "error",
+ "no-lone-blocks": "error",
+ "no-loop-func": "error",
+ "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }],
+ "no-multi-spaces": "error",
+ "no-multi-str": "error",
+ "no-native-reassign": ["error", {"exceptions": ["console"]}],
+ "no-new": "off",
+ "no-new-func": "error",
+ "no-new-wrappers": "error",
+ "no-octal": "error",
+ "no-octal-escape": "error",
+ "no-param-reassign": "off",
+ "no-process-env": "off",
+ "no-proto": "error",
+ "no-redeclare": "error",
+ "no-return-assign": "warn",
+ "no-script-url": "error",
+ "no-self-assign": "error",
+ "no-self-compare": "error",
+ "no-sequences": "error",
+ "no-throw-literal": "error",
+ "no-unmodified-loop-condition": "error",
+ "no-unused-expressions": "error",
+ "no-unused-labels": "error",
+ "no-useless-call": "error",
+ "no-useless-concat": "error",
+ "no-void": "error",
+ "no-warning-comments": "off",
+ "no-with": "error",
+ "radix": ["error", "as-needed"],
+ "vars-on-top": "off",
+ "wrap-iife": ["error", "inside"],
+ "yoda": "error",
+
+ # Strict Mode
+
+ "strict": ["error", "never"],
+
+ # Variables
+
+ "init-declarations": ["error", "always"],
+ "no-catch-shadow": "error",
+ "no-delete-var": "error",
+ "no-label-var": "error",
+ "no-restricted-globals": "off",
+ "no-shadow": "error",
+ "no-shadow-restricted-names": "error",
+ "no-undef": "error",
+ "no-undef-init": "off",
+ "no-undefined": "off",
+ "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }],
+ "no-use-before-define": "error",
+
+ # Node.js and CommonJS
+
+ "callback-return": "warn",
+ "global-require": "error",
+ "handle-callback-err": "warn",
+ "no-mixed-requires": "error",
+ "no-new-require": "error",
+ "no-path-concat": "error",
+ "no-process-exit": "error",
+
+ # Stylistic Issues
+
+ "array-bracket-spacing": ["error", "never"],
+ "block-spacing": ["error", "always"],
+ "brace-style": ["error", "1tbs", { "allowSingleLine": false }],
+ "camelcase": "off",
+ "comma-spacing": ["error", {"before": false, "after": true}],
+ "comma-style": ["error", "last"],
+ "computed-property-spacing": ["error", "never"],
+ "consistent-this": ["error", "self"],
+ "eol-last": "error",
+ "func-names": "off",
+ "func-style": ["error", "declaration"],
+ "indent": ["error", 2, {"SwitchCase": 1}],
+ "key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
+ "keyword-spacing": ["error", { "before": true, "after": true}],
+ "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }],
+ "max-depth": ["error", {"maximum": 5}],
+ "max-nested-callbacks": ["error", 4],
+ "max-params": ["error", 6],
+ "max-statements": "off",
+ "max-statements-per-line": ["error", { "max": 1 }],
+ "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}],
+ "new-parens": "error",
+ "newline-after-var": "off",
+ "newline-before-return": "off",
+ "newline-per-chained-call": "off",
+ "no-array-constructor": "error",
+ "no-bitwise": "error",
+ "no-continue": "error",
+ "no-inline-comments": "off",
+ "no-lonely-if": "warn",
+ "no-mixed-spaces-and-tabs": "error",
+ "no-multiple-empty-lines": ["error", { "max": 1 }],
+ "no-negated-condition": "warn",
+ "no-nested-ternary": "error",
+ "no-new-object": "error",
+ "no-plusplus": "off",
+ "no-restricted-syntax": "off",
+ "no-spaced-func": "error",
+ "no-ternary": "off",
+ "no-trailing-spaces": "error",
+ "no-underscore-dangle": ["error", { "allowAfterThis": true }],
+ "no-unneeded-ternary": "error",
+ "no-whitespace-before-property": "error",
+ "object-curly-spacing": ["error", "always"],
+ "one-var": ["error", "never"],
+ "one-var-declaration-per-line": ["error", "always"],
+ "operator-assignment": ["off", "never"],
+ "operator-linebreak": ["error", "after"],
+ "quote-props": ["error", "as-needed"],
+ "quotes": ["error", "single"],
+ "require-jsdoc": "off",
+ "semi": "error",
+ "semi-spacing": ["error", { "before": false, "after": true }],
+ "sort-vars": "off",
+ "space-before-blocks": ["error", "always"],
+ "space-before-function-paren": ["error", "never"],
+ "space-in-parens": "off",
+ "space-infix-ops": "off",
+ "space-unary-ops": "off",
+ "spaced-comment": "error",
+ "wrap-regex": "error",
+
+ # React
+
+ "react/jsx-boolean-value": [2, "always"],
+ "react/jsx-uses-vars": 2,
+ "react/jsx-closing-bracket-location": 2,
+ "react/jsx-tag-spacing": ["error"],
+ "react/jsx-curly-spacing": [2, "never"],
+ "react/jsx-equals-spacing": [2, "never"],
+ "react/jsx-indent-props": [2, 2],
+ "react/jsx-indent": [2, 2],
+ "react/jsx-key": 2,
+ "react/jsx-no-bind": [2, { "allowArrowFunctions": true }],
+ "react/jsx-no-duplicate-props": [2, { "ignoreCase": true }],
+ "react/jsx-max-props-per-line": [2, { "maximum": 2 }],
+ "react/jsx-handler-names": [2, { "eventHandlerPrefix": "(on|dispatch)", "eventHandlerPropPrefix": "on" }],
+ "react/jsx-no-undef": 2,
+ "react/jsx-pascal-case": 2,
+ "react/jsx-uses-react": 2,
+ // Explicitly disabled in case we want to enable them again
+ "react/no-did-mount-set-state": 0,
+ "react/no-did-update-set-state": 0,
+ "react/no-direct-mutation-state": 2,
+ "react/no-multi-comp": [2, { "ignoreStateless": true }],
+ "react/no-unknown-property": 2,
+ "react/prefer-es6-class": 2,
+ "react/prop-types": 2,
+ "react/react-in-jsx-scope": 2,
+ "react/self-closing-comp": 2,
+ "react/sort-comp": 2,
+ "react/jsx-wrap-multilines": 2
+ }
+}
diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc
new file mode 100644
index 000000000..50aa6aa29
--- /dev/null
+++ b/frontend/.jsbeautifyrc
@@ -0,0 +1,12 @@
+{
+ "js": {
+ "indent_size": 2,
+ "indent_char": " ",
+ "indent_level": 2,
+ "indent_with_tabs": false,
+ "preserve_newlines": true,
+ "brace_style": "collapse",
+ "max_preserve_newlines": 2,
+ "jslint_happy": true
+ }
+}
\ No newline at end of file
diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc
new file mode 100644
index 000000000..5587e5d4d
--- /dev/null
+++ b/frontend/.stylelintrc
@@ -0,0 +1,396 @@
+{
+"plugins": [
+ "stylelint-order"
+],
+"ignoreFiles": [
+ "frontend/src/Styles/scaffolding.css",
+ "**/*.js"
+],
+"rules": {
+ "at-rule-empty-line-before": [
+ "always",
+ {
+ "except": [
+ "inside-block"
+ ]
+ }
+ ],
+ "at-rule-name-case": "lower",
+ "at-rule-name-newline-after": "always-multi-line",
+ "at-rule-name-space-after": "always",
+ "at-rule-no-unknown": [
+ true,
+ {
+ "ignoreAtRules": [
+ "/^add\\-mixin$/",
+ "/^define\\-mixin$/"
+ ]
+ }
+ ],
+ "at-rule-no-vendor-prefix": true,
+ "at-rule-semicolon-newline-after": "always",
+ "at-rule-semicolon-space-before": "never",
+ "block-closing-brace-empty-line-before": "never",
+ "block-closing-brace-newline-after": "always",
+ "block-closing-brace-newline-before": "always",
+ "block-closing-brace-space-after": "always-single-line",
+ "block-closing-brace-space-before": "always-single-line",
+ "block-no-empty": true,
+ "block-opening-brace-newline-after": "always",
+ "block-opening-brace-newline-before": "never-single-line",
+ "block-opening-brace-space-after": "always-single-line",
+ "block-opening-brace-space-before": "always",
+ "color-hex-case": "lower",
+ "color-hex-length": "short",
+ "color-named": "never",
+ "color-no-invalid-hex": true,
+ "comment-whitespace-inside": "always",
+ "declaration-bang-space-after": "never",
+ "declaration-bang-space-before": "always",
+ "declaration-block-no-duplicate-properties": [
+ true,
+ {
+ "ignoreProperties": [
+ "composes"
+ ]
+ }
+ ],
+ "declaration-block-no-redundant-longhand-properties": true,
+ "declaration-block-no-shorthand-property-overrides": true,
+ "declaration-block-semicolon-newline-after": "always",
+ "declaration-block-semicolon-newline-before": "never-multi-line",
+ "declaration-block-semicolon-space-before": "never",
+ "declaration-block-single-line-max-declarations": 1,
+ "declaration-block-trailing-semicolon": "always",
+ "declaration-colon-space-after": "always",
+ "declaration-colon-space-before": "never",
+ "font-family-name-quotes": "always-unless-keyword",
+ "function-calc-no-unspaced-operator": true,
+ "function-comma-newline-after": "never-multi-line",
+ "function-comma-newline-before": "never-multi-line",
+ "function-comma-space-after": "always",
+ "function-comma-space-before": "never",
+ "function-linear-gradient-no-nonstandard-direction": true,
+ "function-name-case": "lower",
+ "function-parentheses-newline-inside": "never-multi-line",
+ "function-parentheses-space-inside": "never",
+ "function-url-quotes": "always",
+ "function-url-scheme-blacklist": [
+ "data"
+ ],
+ "function-whitespace-after": "always",
+ "indentation": 2,
+ "keyframe-declaration-no-important": true,
+ "length-zero-no-unit": true,
+ "max-empty-lines": 1,
+ "max-line-length": [
+ 100,
+ {
+ "ignore": [
+ "non-comments"
+ ]
+ }
+ ],
+ "max-nesting-depth": 2,
+ "media-feature-colon-space-after": "always",
+ "media-feature-colon-space-before": "never",
+ "media-feature-name-case": "lower",
+ "media-feature-name-no-vendor-prefix": true,
+ "media-feature-range-operator-space-after": "always",
+ "media-feature-range-operator-space-before": "always",
+ "no-empty-source": true,
+ "no-eol-whitespace": true,
+ "no-extra-semicolons": true,
+ "no-invalid-double-slash-comments": true,
+ "no-missing-end-of-source-newline": true,
+ "number-leading-zero": "always",
+ "number-no-trailing-zeros": true,
+ "order/order": [
+ "custom-properties",
+ "dollar-variables",
+ {
+ "hasBlock": false,
+ "name": "add-mixin",
+ "type": "at-rule"
+ },
+ "declarations",
+ "rules",
+ "at-rules"
+ ],
+ "order/properties-order": [
+ {
+ "emptyLineBefore": "always",
+ "properties": [
+ "composes"
+ ]
+ },
+ {
+ "emptyLineBefore": "always",
+ "properties": [
+ "position",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "z-index",
+ "display",
+ "visibility",
+ "align-content",
+ "align-items",
+ "align-self",
+ "justify-content",
+ "flex",
+ "flex-direction",
+ "flex-order",
+ "flex-pack",
+ "flex-align",
+ "flex-grow",
+ "flex-shrink",
+ "flex-basis",
+ "flex-wrap",
+ "flex-flow",
+ "float",
+ "clear",
+ "overflow",
+ "overflow-x",
+ "overflow-y",
+ "-webkit-overflow-scrolling",
+ "clip",
+ "box-sizing",
+ "margin",
+ "margin-top",
+ "margin-right",
+ "margin-bottom",
+ "margin-left",
+ "padding",
+ "padding-top",
+ "padding-right",
+ "padding-bottom",
+ "padding-left",
+ "min-width",
+ "min-height",
+ "max-width",
+ "max-height",
+ "width",
+ "height",
+ "outline",
+ "outline-width",
+ "outline-style",
+ "outline-color",
+ "outline-offset",
+ "border",
+ "border-spacing",
+ "border-collapse",
+ "border-width",
+ "border-style",
+ "border-color",
+ "border-top",
+ "border-top-width",
+ "border-top-style",
+ "border-top-color",
+ "border-right",
+ "border-right-width",
+ "border-right-style",
+ "border-right-color",
+ "border-bottom",
+ "border-bottom-width",
+ "border-bottom-style",
+ "border-bottom-color",
+ "border-left",
+ "border-left-width",
+ "border-left-style",
+ "border-left-color",
+ "border-radius",
+ "border-top-left-radius",
+ "border-top-right-radius",
+ "border-bottom-right-radius",
+ "border-bottom-left-radius",
+ "border-image",
+ "border-image-source",
+ "border-image-slice",
+ "border-image-width",
+ "border-image-outset",
+ "border-image-repeat",
+ "border-top-image",
+ "border-right-image",
+ "border-bottom-image",
+ "border-left-image",
+ "border-corner-image",
+ "border-top-left-image",
+ "border-top-right-image",
+ "border-bottom-right-image",
+ "border-bottom-left-image",
+ "background",
+ "background-color",
+ "background-image",
+ "background-attachment",
+ "background-position",
+ "background-position-x",
+ "background-position-y",
+ "background-clip",
+ "background-origin",
+ "background-size",
+ "background-repeat",
+ "box-decoration-break",
+ "box-shadow",
+ "color",
+ "table-layout",
+ "caption-side",
+ "empty-cells",
+ "list-style",
+ "list-style-position",
+ "list-style-type",
+ "list-style-image",
+ "quotes",
+ "content",
+ "counter-increment",
+ "counter-reset",
+ "-ms-writing-mode",
+ "vertical-align",
+ "text-align",
+ "text-align-last",
+ "text-decoration",
+ "text-emphasis",
+ "text-emphasis-position",
+ "text-emphasis-style",
+ "text-emphasis-color",
+ "text-indent",
+ "text-justify",
+ "text-outline",
+ "text-transform",
+ "text-wrap",
+ "text-overflow",
+ "text-overflow-ellipsis",
+ "text-overflow-mode",
+ "text-shadow",
+ "white-space",
+ "word-spacing",
+ "word-wrap",
+ "word-break",
+ "tab-size",
+ "hyphens",
+ "letter-spacing",
+ "font",
+ "font-weight",
+ "font-style",
+ "font-variant",
+ "font-size-adjust",
+ "font-stretch",
+ "font-size",
+ "font-family",
+ "font-smoothing",
+ "-moz-osx-font-smoothing",
+ "-webkit-font-smoothing",
+ "src",
+ "line-height",
+ "opacity",
+ "filter",
+ "resize",
+ "cursor",
+ "appearance",
+ "nav-index",
+ "nav-up",
+ "nav-right",
+ "nav-down",
+ "nav-left",
+ "transition",
+ "transition-delay",
+ "transition-timing-function",
+ "transition-duration",
+ "transition-property",
+ "transform",
+ "transform-origin",
+ "transform-style",
+ "backface-visibility",
+ "animation",
+ "animation-name",
+ "animation-duration",
+ "animation-play-state",
+ "animation-timing-function",
+ "animation-delay",
+ "animation-iteration-count",
+ "animation-direction",
+ "animation-fill-mode",
+ "pointer-events",
+ "user-select",
+ "touch-action",
+ "-webkit-tap-highlight-color",
+ "unicode-bidi",
+ "direction",
+ "columns",
+ "column-span",
+ "column-width",
+ "column-count",
+ "column-fill",
+ "column-gap",
+ "column-rule",
+ "column-rule-width",
+ "column-rule-style",
+ "column-rule-color",
+ "break-before",
+ "break-inside",
+ "break-after",
+ "page-break-before",
+ "page-break-inside",
+ "page-break-after",
+ "orphans",
+ "widows",
+ "zoom",
+ "max-zoom",
+ "min-zoom",
+ "user-zoom",
+ "orientation"
+ ]
+ }
+ ],
+ "property-case": "lower",
+ "property-no-vendor-prefix": true,
+ "rule-empty-line-before": [
+ "always",
+ {
+ "except": [
+ "first-nested"
+ ],
+ "ignore": [
+ "after-comment"
+ ]
+ }
+ ],
+ "selector-attribute-brackets-space-inside": "never",
+ "selector-attribute-operator-space-after": "never",
+ "selector-attribute-operator-space-before": "never",
+ "selector-attribute-quotes": "never",
+ "selector-class-pattern": "^[A-Za-z0-9]+$",
+ "selector-combinator-space-after": "always",
+ "selector-combinator-space-before": "always",
+ "selector-descendant-combinator-no-non-space": true,
+ "selector-list-comma-newline-after": "always",
+ "selector-list-comma-newline-before": "never-multi-line",
+ "selector-list-comma-space-before": "never",
+ "selector-max-attribute": 0,
+ "selector-max-class": 3,
+ "selector-max-compound-selectors": 3,
+ "selector-max-empty-lines": 0,
+ "selector-max-id": 0,
+ "selector-max-universal": 0,
+ "selector-pseudo-class-case": "lower",
+ "selector-pseudo-class-parentheses-space-inside": "never",
+ "selector-pseudo-element-case": "lower",
+ "selector-pseudo-element-colon-notation": "double",
+ "selector-pseudo-element-no-unknown": true,
+ "selector-type-case": "lower",
+ "selector-type-no-unknown": true,
+ "shorthand-property-no-redundant-values": true,
+ "string-no-newline": true,
+ "string-quotes": "single",
+ "time-min-milliseconds": 100,
+ "unit-case": "lower",
+ "unit-no-unknown": true,
+ "value-list-comma-newline-after": "never-multi-line",
+ "value-list-comma-newline-before": "never-multi-line",
+ "value-list-comma-space-after": "always",
+ "value-list-comma-space-before": "never",
+ "value-list-max-empty-lines": 0,
+ "value-no-vendor-prefix": true
+ }
+}
diff --git a/frontend/.tern-project b/frontend/.tern-project
new file mode 100644
index 000000000..aa9d76407
--- /dev/null
+++ b/frontend/.tern-project
@@ -0,0 +1,7 @@
+{
+ "ecmaVersion": 6,
+ "libs": [
+ "browser",
+ "jquery"
+ ]
+}
diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js
new file mode 100644
index 000000000..cfeb5d138
--- /dev/null
+++ b/frontend/gulp/build.js
@@ -0,0 +1,15 @@
+const gulp = require('gulp');
+const runSequence = require('run-sequence');
+
+require('./clean');
+require('./copy');
+
+gulp.task('build', () => {
+ return runSequence('clean', [
+ 'webpack',
+ 'copyHtml',
+ 'copyFonts',
+ 'copyImages',
+ 'copyJs'
+ ]);
+});
diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js
new file mode 100644
index 000000000..ac2e4026f
--- /dev/null
+++ b/frontend/gulp/clean.js
@@ -0,0 +1,8 @@
+const gulp = require('gulp');
+const del = require('del');
+
+const paths = require('./helpers/paths');
+
+gulp.task('clean', () => {
+ return del([paths.dest.root]);
+});
diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js
new file mode 100644
index 000000000..5b48eb755
--- /dev/null
+++ b/frontend/gulp/copy.js
@@ -0,0 +1,45 @@
+var path = require('path');
+var gulp = require('gulp');
+var print = require('gulp-print').default;
+var cache = require('gulp-cached');
+var livereload = require('gulp-livereload');
+var paths = require('./helpers/paths.js');
+
+gulp.task('copyJs', () => {
+ return gulp.src(
+ [
+ path.join(paths.src.root, 'polyfills.js')
+ ])
+ .pipe(cache('copyJs'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyHtml', () => {
+ return gulp.src(paths.src.html)
+ .pipe(cache('copyHtml'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyFonts', () => {
+ return gulp.src(
+ path.join(paths.src.fonts, '**', '*.*')
+ )
+ .pipe(cache('copyFonts'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.fonts))
+ .pipe(livereload());
+});
+
+gulp.task('copyImages', () => {
+ return gulp.src(
+ path.join(paths.src.images, '**', '*.*')
+ )
+ .pipe(cache('copyImages'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.images))
+ .pipe(livereload());
+});
diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js
new file mode 100644
index 000000000..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..8988c7ad4
--- /dev/null
+++ b/frontend/gulp/imageMin.js
@@ -0,0 +1,15 @@
+var gulp = require('gulp');
+var print = require('gulp-print').default;
+var paths = require('./helpers/paths.js');
+
+gulp.task('imageMin', () => {
+ var imagemin = require('gulp-imagemin');
+ return gulp.src(paths.src.images)
+ .pipe(imagemin({
+ progressive: false,
+ optimizationLevel: 4,
+ svgoPlugins: [{ removeViewBox: false }]
+ }))
+ .pipe(print())
+ .pipe(gulp.dest(paths.src.content + 'Images/'));
+});
diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js
new file mode 100644
index 000000000..013250194
--- /dev/null
+++ b/frontend/gulp/start.js
@@ -0,0 +1,104 @@
+// will download and run radarr (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://radarr.aeonlucid.com/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/Radarr.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..ba1a47b66
--- /dev/null
+++ b/frontend/gulp/watch.js
@@ -0,0 +1,27 @@
+const gulp = require('gulp');
+const livereload = require('gulp-livereload');
+const watch = require('gulp-watch');
+const paths = require('./helpers/paths.js');
+
+require('./copy.js');
+require('./webpack.js');
+
+function watchTask(glob, task) {
+ const options = {
+ name: `watch: ${task}`,
+ verbose: true
+ };
+ return watch(glob, options, () => {
+ gulp.start(task);
+ });
+}
+
+gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => {
+ livereload.listen({ start: true });
+
+ gulp.start('webpackWatch');
+
+ watchTask(paths.src.html, 'copyHtml');
+ watchTask(`${paths.src.fonts}**/*.*`, 'copyFonts');
+ watchTask(`${paths.src.images}**/*.*`, 'copyImages');
+});
diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js
new file mode 100644
index 000000000..50aefcc1a
--- /dev/null
+++ b/frontend/gulp/webpack.js
@@ -0,0 +1,212 @@
+const gulp = require('gulp');
+const webpackStream = require('webpack-stream');
+const livereload = require('gulp-livereload');
+const path = require('path');
+const webpack = require('webpack');
+const errorHandler = require('./helpers/errorHandler');
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
+const UglifyJSPlugin = require('uglifyjs-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 cssVarsFiles = [
+ '../src/Styles/Variables/colors',
+ '../src/Styles/Variables/dimensions',
+ '../src/Styles/Variables/fonts',
+ '../src/Styles/Variables/animations'
+].map(require.resolve);
+
+const extractCSSPlugin = new ExtractTextPlugin({
+ filename: path.join('_output', uiFolder, 'Content', 'styles.css'),
+ allChunks: true,
+ disable: false,
+ ignoreOrder: true
+});
+
+const plugins = [
+ extractCSSPlugin,
+
+ new webpack.optimize.CommonsChunkPlugin({
+ name: 'vendor'
+ }),
+
+ new webpack.DefinePlugin({
+ __DEV__: !isProduction,
+ 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development')
+ })
+];
+
+if (isProduction) {
+ plugins.push(new UglifyJSPlugin({
+ sourceMap: true,
+ uglifyOptions: {
+ mangle: false,
+ output: {
+ comments: false,
+ beautify: true
+ }
+ }
+ }));
+}
+
+const config = {
+ devtool: '#source-map',
+
+ stats: {
+ children: false
+ },
+
+ watchOptions: {
+ ignored: /node_modules/
+ },
+
+ entry: {
+ preload: 'preload.js',
+ vendor: 'vendor.js',
+ index: 'index.js'
+ },
+
+ resolve: {
+ modules: [
+ root,
+ path.join(root, 'Shims'),
+ 'node_modules'
+ ],
+ alias: {
+ jquery: 'jquery/src/jquery'
+ }
+ },
+
+ output: {
+ filename: path.join('_output', uiFolder, '[name].js'),
+ sourceMapFilename: '[file].map'
+ },
+
+ plugins,
+
+ resolveLoader: {
+ modules: [
+ 'node_modules',
+ 'frontend/gulp/webpack/'
+ ]
+ },
+
+ module: {
+ rules: [
+ {
+ test: /\.js?$/,
+ exclude: /(node_modules|JsLibraries)/,
+ loader: 'babel-loader',
+ 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)/,
+ use: extractCSSPlugin.extract({
+ fallback: 'style-loader',
+ use: [
+ {
+ loader: 'css-variables-loader',
+ options: {
+ cssVarsFiles
+ }
+ },
+ {
+ loader: 'css-loader',
+ options: {
+ modules: true,
+ importLoaders: 1,
+ localIdentName: '[name]-[local]-[hash:base64:5]',
+ sourceMap: true
+ }
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ config: {
+ ctx: {
+ cssVarsFiles
+ },
+ path: 'frontend/postcss.config.js'
+ }
+ }
+ }
+ ]
+ })
+ },
+
+ // Global styles
+ {
+ test: /\.css$/,
+ include: /(node_modules|globals.css)/,
+ use: [
+ 'style-loader',
+ {
+ loader: 'css-loader'
+ }
+ ]
+ },
+
+ // Fonts
+ {
+ test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
+ use: [
+ {
+ loader: 'url-loader',
+ options: {
+ limit: 10240,
+ mimetype: 'application/font-woff',
+ emitFile: false,
+ name: 'Content/Fonts/[name].[ext]'
+ }
+ }
+ ]
+ },
+
+ {
+ test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
+ use: [
+ {
+ loader: 'file-loader',
+ options: {
+ emitFile: false,
+ name: 'Content/Fonts/[name].[ext]'
+ }
+ }
+ ]
+ }
+ ]
+ }
+};
+
+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/gulp/webpack/css-variables-loader.js b/frontend/gulp/webpack/css-variables-loader.js
new file mode 100644
index 000000000..5683c98be
--- /dev/null
+++ b/frontend/gulp/webpack/css-variables-loader.js
@@ -0,0 +1,11 @@
+const loaderUtils = require('loader-utils');
+
+module.exports = function cssVariablesLoader(source) {
+ const options = loaderUtils.getOptions(this);
+
+ options.cssVarsFiles.forEach((cssVarsFile) => {
+ this.addDependency(cssVarsFile);
+ });
+
+ return source;
+};
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 000000000..f82554ba8
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,33 @@
+const reload = require('require-nocache')(module);
+
+module.exports = (ctx, configPath, options) => {
+ const config = {
+ plugins: {
+ 'postcss-mixins': {
+ mixinsDir: [
+ 'frontend/src/Styles/Mixins'
+ ]
+ },
+ 'postcss-simple-vars': {
+ variables: () =>
+ ctx.options.cssVarsFiles.reduce((acc, vars) => {
+ return Object.assign(acc, reload(vars));
+ }, {})
+ },
+ 'postcss-nested': {},
+ autoprefixer: {
+ browsers: [
+ 'Chrome >= 30',
+ 'Firefox >= 30',
+ 'Safari >= 6',
+ 'Edge >= 12',
+ 'Explorer >= 11',
+ 'iOS >= 7',
+ 'Android >= 4.4'
+ ]
+ }
+ }
+ };
+
+ return config;
+};
diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json
new file mode 100644
index 000000000..0fb2bf460
--- /dev/null
+++ b/frontend/src/.vscode/settings.json
@@ -0,0 +1,4 @@
+// Place your settings in this file to overwrite default and user settings.
+{
+ "files.insertFinalNewline": true
+}
\ No newline at end of file
diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js
new file mode 100644
index 000000000..d93bec0bf
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/Blacklist.js
@@ -0,0 +1,123 @@
+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 TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+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..b182e7bb2
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js
@@ -0,0 +1,154 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as blacklistActions from 'Store/Actions/blacklistActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import Blacklist from './Blacklist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.blacklist,
+ createCommandExecutingSelector(commandNames.CLEAR_BLACKLIST),
+ (blacklist, isClearingBlacklistExecuting) => {
+ return {
+ isClearingBlacklistExecuting,
+ ...blacklist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...blacklistActions,
+ executeCommand
+};
+
+class BlacklistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchBlacklist,
+ gotoBlacklistFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchBlacklist();
+ } else {
+ gotoBlacklistFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearBlacklist();
+ unregisterPagePopulator(this.repopulate);
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchBlacklist();
+ }
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoBlacklistFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoBlacklistPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoBlacklistNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoBlacklistLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoBlacklistPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setBlacklistSort({ sortKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setBlacklistTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ onClearBlacklistPress = () => {
+ this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setBlacklistTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BlacklistConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ isClearingBlacklistExecuting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchBlacklist: PropTypes.func.isRequired,
+ gotoBlacklistFirstPage: PropTypes.func.isRequired,
+ gotoBlacklistPreviousPage: PropTypes.func.isRequired,
+ gotoBlacklistNextPage: PropTypes.func.isRequired,
+ gotoBlacklistLastPage: PropTypes.func.isRequired,
+ gotoBlacklistPage: PropTypes.func.isRequired,
+ setBlacklistSort: PropTypes.func.isRequired,
+ setBlacklistTableOption: PropTypes.func.isRequired,
+ clearBlacklist: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector)
+);
diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js
new file mode 100644
index 000000000..356512a9d
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+class BlacklistDetailsModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ sourceTitle,
+ protocol,
+ indexer,
+ message,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+
+ Details
+
+
+
+
+
+
+
+
+ {
+ !!message &&
+
+ }
+
+ {
+ !!message &&
+
+ }
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+BlacklistDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ message: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default BlacklistDetailsModal;
diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css
new file mode 100644
index 000000000..b62d1e750
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.css
@@ -0,0 +1,18 @@
+.language,
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.indexer {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 80px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js
new file mode 100644
index 000000000..39d4dbd0a
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.js
@@ -0,0 +1,170 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import MovieQuality from 'Movie/MovieQuality';
+import MovieTitleLink from 'Movie/MovieTitleLink';
+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 {
+ movie,
+ sourceTitle,
+ quality,
+ date,
+ protocol,
+ indexer,
+ message,
+ columns,
+ onRemovePress
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'movie.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'sourceTitle') {
+ return (
+
+ {sourceTitle}
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'date') {
+ return (
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+BlacklistRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ movie: PropTypes.object.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ quality: PropTypes.object.isRequired,
+ date: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ message: PropTypes.string,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onRemovePress: PropTypes.func.isRequired
+};
+
+export default BlacklistRow;
diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
new file mode 100644
index 000000000..275a02464
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { removeFromBlacklist } from 'Store/Actions/blacklistActions';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import BlacklistRow from './BlacklistRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieSelector(),
+ (movie) => {
+ return {
+ movie
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onRemovePress() {
+ dispatch(removeFromBlacklist({ id: props.id }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(BlacklistRow);
diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css
new file mode 100644
index 000000000..03f8fd3ce
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.css
@@ -0,0 +1,5 @@
+.description {
+ composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
+
+ overflow-wrap: break-word;
+}
diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js
new file mode 100644
index 000000000..6214eeb7e
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.js
@@ -0,0 +1,244 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import Link from 'Components/Link/Link';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+import styles from './HistoryDetails.css';
+
+function HistoryDetails(props) {
+ const {
+ eventType,
+ sourceTitle,
+ data,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (eventType === 'grabbed') {
+ const {
+ indexer,
+ releaseGroup,
+ nzbInfoUrl,
+ downloadClient,
+ downloadId,
+ age,
+ ageHours,
+ ageMinutes,
+ publishedDate
+ } = data;
+
+ return (
+
+
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!releaseGroup &&
+
+ }
+
+ {
+ !!nzbInfoUrl &&
+
+
+ Info URL
+
+
+
+ {nzbInfoUrl}
+
+
+ }
+
+ {
+ !!downloadClient &&
+
+ }
+
+ {
+ !!downloadId &&
+
+ }
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!publishedDate &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'downloadFailed') {
+ const {
+ message
+ } = data;
+
+ return (
+
+
+
+ {
+ !!message &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'downloadFolderImported') {
+ const {
+ droppedPath,
+ importedPath
+ } = data;
+
+ return (
+
+
+
+ {
+ !!droppedPath &&
+
+ }
+
+ {
+ !!importedPath &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'episodeFileDeleted') {
+ const {
+ reason
+ } = data;
+
+ let reasonMessage = '';
+
+ switch (reason) {
+ case 'Manual':
+ reasonMessage = 'File was deleted by via UI';
+ break;
+ case 'MissingFromDisk':
+ reasonMessage = 'Radarr 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..2d18a5e6a
--- /dev/null
+++ b/frontend/src/Activity/History/History.js
@@ -0,0 +1,163 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons } from 'Helpers/Props';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import HistoryRowConnector from './HistoryRowConnector';
+
+class History extends Component {
+
+ //
+ // Lifecycle
+
+ shouldComponentUpdate(nextProps) {
+ // Don't update when fetching has completed if items have changed,
+ // before episodes start fetching or when episodes start fetching.
+
+ if (
+ (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items)
+ )
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ selectedFilterKey,
+ filters,
+ totalRecords,
+ onFilterSelect,
+ onFirstPagePress,
+ ...otherProps
+ } = this.props;
+
+ const hasError = error;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && 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
+
+ }
+
+ {
+ isPopulated && !hasError && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+ }
+}
+
+History.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ 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..df780c1ed
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryConnector.js
@@ -0,0 +1,134 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import withCurrentPage from 'Components/withCurrentPage';
+import * as historyActions from 'Store/Actions/historyActions';
+import History from './History';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.history,
+ (history) => {
+ return {
+ ...history
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...historyActions
+};
+
+class HistoryConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchHistory,
+ gotoHistoryFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchHistory();
+ } else {
+ gotoHistoryFirstPage();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearHistory();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchHistory();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoHistoryFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoHistoryPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoHistoryNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoHistoryLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoHistoryPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setHistorySort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setHistoryFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setHistoryTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoHistoryFirstPage();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HistoryConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ 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
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector)
+);
diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css
new file mode 100644
index 000000000..fac97a6c7
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryEventTypeCell.css
@@ -0,0 +1,6 @@
+.cell {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 35px;
+ text-align: center;
+}
diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js
new file mode 100644
index 000000000..f013b3f55
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryEventTypeCell.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './HistoryEventTypeCell.css';
+
+function getIconName(eventType) {
+ switch (eventType) {
+ case 'grabbed':
+ return icons.DOWNLOADING;
+ case 'seriesFolderImported':
+ return icons.DRIVE;
+ case 'downloadFolderImported':
+ return icons.DOWNLOADED;
+ case 'downloadFailed':
+ return icons.DOWNLOADING;
+ case 'episodeFileDeleted':
+ return icons.DELETE;
+ case 'episodeFileRenamed':
+ return icons.ORGANIZE;
+ default:
+ return icons.UNKNOWN;
+ }
+}
+
+function getIconKind(eventType) {
+ switch (eventType) {
+ case 'downloadFailed':
+ return kinds.DANGER;
+ default:
+ return kinds.DEFAULT;
+ }
+}
+
+function getTooltip(eventType, data) {
+ switch (eventType) {
+ case 'grabbed':
+ return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
+ case 'seriesFolderImported':
+ return 'Episode imported from series folder';
+ case 'downloadFolderImported':
+ return 'Episode downloaded successfully and picked up from download client';
+ case 'downloadFailed':
+ return 'Episode download failed';
+ case 'episodeFileDeleted':
+ return 'Episode file deleted';
+ case 'episodeFileRenamed':
+ return 'Episode file renamed';
+ default:
+ return 'Unknown event';
+ }
+}
+
+function HistoryEventTypeCell({ eventType, data }) {
+ const iconName = getIconName(eventType);
+ const iconKind = getIconKind(eventType);
+ const tooltip = getTooltip(eventType, data);
+
+ return (
+
+
+
+ );
+}
+
+HistoryEventTypeCell.propTypes = {
+ eventType: PropTypes.string.isRequired,
+ data: PropTypes.object
+};
+
+HistoryEventTypeCell.defaultProps = {
+ data: {}
+};
+
+export default HistoryEventTypeCell;
diff --git a/frontend/src/Activity/History/HistoryRow.css b/frontend/src/Activity/History/HistoryRow.css
new file mode 100644
index 000000000..83586af58
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRow.css
@@ -0,0 +1,23 @@
+.downloadClient {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 120px;
+}
+
+.indexer {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 80px;
+}
+
+.releaseGroup {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 110px;
+}
+
+.details {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js
new file mode 100644
index 000000000..3c9fba4f3
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRow.js
@@ -0,0 +1,212 @@
+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 MovieQuality from 'Movie/MovieQuality';
+import MovieTitleLink from 'Movie/MovieTitleLink';
+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 {
+ movie,
+ quality,
+ qualityCutoffNotMet,
+ eventType,
+ sourceTitle,
+ date,
+ data,
+ isMarkingAsFailed,
+ columns,
+ shortDateFormat,
+ timeFormat,
+ onMarkAsFailedPress
+ } = this.props;
+
+ if (!movie) {
+ return null;
+ }
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'eventType') {
+ return (
+
+ );
+ }
+
+ if (name === 'movie.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'date') {
+ return (
+
+ );
+ }
+
+ if (name === 'downloadClient') {
+ return (
+
+ {data.downloadClient}
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {data.indexer}
+
+ );
+ }
+
+ if (name === 'releaseGroup') {
+ return (
+
+ {data.releaseGroup}
+
+ );
+ }
+
+ if (name === 'details') {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+HistoryRow.propTypes = {
+ movieId: PropTypes.number,
+ movie: PropTypes.object.isRequired,
+ language: PropTypes.object.isRequired,
+ languageCutoffNotMet: PropTypes.bool.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ isMarkingAsFailed: PropTypes.bool,
+ markAsFailedError: PropTypes.object,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+export default HistoryRow;
diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js
new file mode 100644
index 000000000..b68c0ba4c
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRowConnector.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 { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import HistoryRow from './HistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieSelector(),
+ createUISettingsSelector(),
+ (movie, uiSettings) => {
+ return {
+ movie,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHistory,
+ markAsFailed
+};
+
+class HistoryRowConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.isMarkingAsFailed &&
+ !this.props.isMarkingAsFailed &&
+ !this.props.markAsFailedError
+ ) {
+ this.props.fetchHistory();
+ }
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = () => {
+ this.props.markAsFailed({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+
+}
+
+HistoryRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isMarkingAsFailed: PropTypes.bool,
+ markAsFailedError: PropTypes.object,
+ fetchHistory: PropTypes.func.isRequired,
+ markAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector);
diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css
new file mode 100644
index 000000000..15e8e4fc6
--- /dev/null
+++ b/frontend/src/Activity/Queue/ProtocolLabel.css
@@ -0,0 +1,13 @@
+.torrent {
+ composes: label from 'Components/Label.css';
+
+ border-color: $torrentColor;
+ background-color: $torrentColor;
+}
+
+.usenet {
+ composes: label from 'Components/Label.css';
+
+ border-color: $usenetColor;
+ background-color: $usenetColor;
+}
diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js
new file mode 100644
index 000000000..e8a08943c
--- /dev/null
+++ b/frontend/src/Activity/Queue/ProtocolLabel.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Label from 'Components/Label';
+import styles from './ProtocolLabel.css';
+
+function ProtocolLabel({ protocol }) {
+ const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
+
+ return (
+
+ {protocolName}
+
+ );
+}
+
+ProtocolLabel.propTypes = {
+ protocol: PropTypes.string.isRequired
+};
+
+export default ProtocolLabel;
diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js
new file mode 100644
index 000000000..b65cc8a0c
--- /dev/null
+++ b/frontend/src/Activity/Queue/Queue.js
@@ -0,0 +1,278 @@
+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 removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+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 PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import RemoveQueueItemsModal from './RemoveQueueItemsModal';
+import QueueOptionsConnector from './QueueOptionsConnector';
+import QueueRowConnector from './QueueRowConnector';
+
+class Queue extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isPendingSelected: false,
+ isConfirmRemoveModalOpen: false
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ // Don't update when fetching has completed if items have changed,
+ // before episodes start fetching or when episodes start fetching.
+
+ if (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items) &&
+ nextProps.items.some((e) => e.episodeId)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+
+ 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,
+ columns,
+ totalRecords,
+ isGrabbing,
+ isRemoving,
+ isCheckForFinishedDownloadExecuting,
+ onRefreshPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmRemoveModalOpen,
+ isPendingSelected
+ } = this.state;
+
+ const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting;
+ const isAllPopulated = isPopulated && !items.length;
+ const hasError = error;
+ const selectedCount = this.getSelectedIds().length;
+ const disableSelectedActions = selectedCount === 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isRefreshing && !isAllPopulated &&
+
+ }
+
+ {
+ !isRefreshing && hasError &&
+
+ Failed to load Queue
+
+ }
+
+ {
+ isPopulated && !hasError && !items.length &&
+
+ Queue is empty
+
+ }
+
+ {
+ isAllPopulated && !hasError && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+
+
+ );
+ }
+}
+
+Queue.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ 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..59bce00c3
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueConnector.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as queueActions from 'Store/Actions/queueActions';
+import * as commandNames from 'Commands/commandNames';
+import Queue from './Queue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.queue.options,
+ (state) => state.queue.paged,
+ createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD),
+ (options, queue, isCheckForFinishedDownloadExecuting) => {
+ return {
+ isCheckForFinishedDownloadExecuting,
+ ...options,
+ ...queue
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...queueActions,
+ executeCommand
+};
+
+class QueueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchQueue,
+ gotoQueueFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchQueue();
+ } else {
+ gotoQueueFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ this.props.includeUnknownMovieItems !==
+ prevProps.includeUnknownMovieItems
+ ) {
+ this.repopulate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearQueue();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchQueue();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoQueueFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoQueuePreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoQueueNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoQueueLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoQueuePage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setQueueSort({ sortKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setQueueTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoQueueFirstPage();
+ }
+ }
+
+ onRefreshPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD
+ });
+ }
+
+ onGrabSelectedPress = (ids) => {
+ this.props.grabQueueItems({ ids });
+ }
+
+ onRemoveSelectedPress = (ids, blacklist) => {
+ this.props.removeQueueItems({ ids, blacklist });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchQueue: PropTypes.func.isRequired,
+ gotoQueueFirstPage: PropTypes.func.isRequired,
+ gotoQueuePreviousPage: PropTypes.func.isRequired,
+ gotoQueueNextPage: PropTypes.func.isRequired,
+ gotoQueueLastPage: PropTypes.func.isRequired,
+ gotoQueuePage: PropTypes.func.isRequired,
+ setQueueSort: PropTypes.func.isRequired,
+ setQueueTableOption: PropTypes.func.isRequired,
+ clearQueue: PropTypes.func.isRequired,
+ grabQueueItems: PropTypes.func.isRequired,
+ removeQueueItems: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(QueueConnector)
+);
diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js
new file mode 100644
index 000000000..f6e360c0a
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueDetails.js
@@ -0,0 +1,97 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+
+function QueueDetails(props) {
+ const {
+ title,
+ size,
+ sizeleft,
+ estimatedCompletionTime,
+ status: queueStatus,
+ errorMessage,
+ progressBar
+ } = props;
+
+ const status = queueStatus.toLowerCase();
+
+ const progress = (100 - sizeleft / size * 100);
+
+ if (status === 'pending') {
+ return (
+
+ );
+ }
+
+ if (status === 'completed') {
+ if (errorMessage) {
+ return (
+
+ );
+ }
+
+ // TODO: show an icon when download is complete, but not imported yet?
+ }
+
+ if (errorMessage) {
+ return (
+
+ );
+ }
+
+ if (status === 'failed') {
+ return (
+
+ );
+ }
+
+ if (status === 'warning') {
+ return (
+
+ );
+ }
+
+ if (progress < 5) {
+ return (
+
+ );
+ }
+
+ return progressBar;
+}
+
+QueueDetails.propTypes = {
+ title: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ estimatedCompletionTime: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ errorMessage: PropTypes.string,
+ progressBar: PropTypes.node.isRequired
+};
+
+export default QueueDetails;
diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js
new file mode 100644
index 000000000..900cf85cb
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueOptions.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+class QueueOptions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ includeUnknownSeriesItems: props.includeUnknownSeriesItems
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ includeUnknownSeriesItems
+ } = this.props;
+
+ if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) {
+ this.setState({
+ includeUnknownSeriesItems
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onOptionChange = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onOptionChange({
+ [name]: value
+ });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ includeUnknownSeriesItems
+ } = this.state;
+
+ return (
+
+
+ Show Unknown Series Items
+
+
+
+
+ );
+ }
+}
+
+QueueOptions.propTypes = {
+ includeUnknownSeriesItems: PropTypes.bool.isRequired,
+ onOptionChange: PropTypes.func.isRequired
+};
+
+export default QueueOptions;
diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js
new file mode 100644
index 000000000..b2c99511c
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setQueueOption } from 'Store/Actions/queueActions';
+import QueueOptions from './QueueOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.queue.options,
+ (options) => {
+ return options;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onOptionChange: setQueueOption
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions);
diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css
new file mode 100644
index 000000000..6aa4a1622
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.css
@@ -0,0 +1,23 @@
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.protocol {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.progress {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js
new file mode 100644
index 000000000..8cc834fbe
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.js
@@ -0,0 +1,326 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ProgressBar from 'Components/ProgressBar';
+import TableRow from 'Components/Table/TableRow';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import MovieQuality from 'Movie/MovieQuality';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import MovieTitleLink from 'Movie/MovieTitleLink';
+import QueueStatusCell from './QueueStatusCell';
+import TimeleftCell from './TimeleftCell';
+import RemoveQueueItemModal from './RemoveQueueItemModal';
+import styles from './QueueRow.css';
+
+class QueueRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRemoveQueueItemModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRemoveQueueItemPress = () => {
+ this.setState({ isRemoveQueueItemModalOpen: true });
+ }
+
+ onRemoveQueueItemModalConfirmed = (blacklist) => {
+ this.props.onRemoveQueueItemPress(blacklist);
+ this.setState({ isRemoveQueueItemModalOpen: false });
+ }
+
+ onRemoveQueueItemModalClose = () => {
+ this.setState({ isRemoveQueueItemModalOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ downloadId,
+ title,
+ status,
+ trackedDownloadStatus,
+ statusMessages,
+ errorMessage,
+ series,
+ episode,
+ quality,
+ protocol,
+ indexer,
+ downloadClient,
+ estimatedCompletionTime,
+ timeleft,
+ size,
+ sizeleft,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isGrabbing,
+ grabError,
+ isRemoving,
+ isSelected,
+ columns,
+ onSelectedChange,
+ onGrabPress
+ } = this.props;
+
+ const {
+ isRemoveQueueItemModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const progress = 100 - (sizeleft / size * 100);
+ const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning';
+ const isPending = status === 'Delay' || status === 'DownloadClientUnavailable';
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'status') {
+ return (
+
+ );
+ }
+
+ if (name === 'series.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'series') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episode.airDateUtc') {
+ return (
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'protocol') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'downloadClient') {
+ return (
+
+ {downloadClient}
+
+ );
+ }
+
+ if (name === 'estimatedCompletionTime') {
+ return (
+
+ );
+ }
+
+ if (name === 'progress') {
+ return (
+
+ {
+ !!progress &&
+
+ }
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ {
+ showInteractiveImport &&
+
+ }
+
+ {
+ isPending &&
+
+ }
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+
+}
+
+QueueRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ downloadId: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ status: PropTypes.string.isRequired,
+ trackedDownloadStatus: PropTypes.string,
+ statusMessages: PropTypes.arrayOf(PropTypes.object),
+ errorMessage: PropTypes.string,
+ series: PropTypes.object.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..3fdeb2b22
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRowConnector.js
@@ -0,0 +1,68 @@
+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 createMovieSelector from 'Store/Selectors/createMovieSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import QueueRow from './QueueRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieSelector(),
+ createUISettingsSelector(),
+ (series, uiSettings) => {
+ const result = _.pick(uiSettings, [
+ 'showRelativeDates',
+ 'shortDateFormat',
+ 'timeFormat'
+ ]);
+
+ result.series = series;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ grabQueueItem,
+ removeQueueItem
+};
+
+class QueueRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ this.props.grabQueueItem({ id: this.props.id });
+ }
+
+ onRemoveQueueItemPress = (blacklist) => {
+ this.props.removeQueueItem({ id: this.props.id, blacklist });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ episode: PropTypes.object,
+ grabQueueItem: PropTypes.func.isRequired,
+ removeQueueItem: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector);
diff --git a/frontend/src/Activity/Queue/QueueStatusCell.css b/frontend/src/Activity/Queue/QueueStatusCell.css
new file mode 100644
index 000000000..6291ec949
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueStatusCell.css
@@ -0,0 +1,5 @@
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js
new file mode 100644
index 000000000..f8cbc65ff
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueStatusCell.js
@@ -0,0 +1,132 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import styles from './QueueStatusCell.css';
+
+function getDetailedPopoverBody(statusMessages) {
+ return (
+
+ {
+ statusMessages.map(({ title, messages }) => {
+ return (
+
+ {title}
+
+ {
+ messages.map((message) => {
+ return (
+
+ {message}
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+
+ );
+}
+
+function QueueStatusCell(props) {
+ const {
+ sourceTitle,
+ status,
+ trackedDownloadStatus = 'Ok',
+ statusMessages,
+ errorMessage
+ } = props;
+
+ const hasWarning = trackedDownloadStatus === 'Warning';
+ const hasError = trackedDownloadStatus === 'Error';
+
+ // status === 'downloading'
+ let iconName = icons.DOWNLOADING;
+ let iconKind = kinds.DEFAULT;
+ let title = 'Downloading';
+
+ if (hasWarning) {
+ iconKind = kinds.WARNING;
+ }
+
+ if (status === 'Paused') {
+ iconName = icons.PAUSED;
+ title = 'Paused';
+ }
+
+ if (status === 'Queued') {
+ iconName = icons.QUEUED;
+ title = 'Queued';
+ }
+
+ if (status === 'Completed') {
+ iconName = icons.DOWNLOADED;
+ title = 'Downloaded';
+ }
+
+ if (status === 'Delay') {
+ iconName = icons.PENDING;
+ title = 'Pending';
+ }
+
+ if (status === 'DownloadClientUnavailable') {
+ iconName = icons.PENDING;
+ iconKind = kinds.WARNING;
+ title = 'Pending - Download client is unavailable';
+ }
+
+ if (status === 'Failed') {
+ iconName = icons.DOWNLOADING;
+ iconKind = kinds.DANGER;
+ title = 'Download failed';
+ }
+
+ if (status === 'Warning') {
+ iconName = icons.DOWNLOADING;
+ iconKind = kinds.WARNING;
+ title = `Download warning: ${errorMessage || 'check download client for more details'}`;
+ }
+
+ if (hasError) {
+ if (status === 'Completed') {
+ iconName = icons.DOWNLOAD;
+ iconKind = kinds.DANGER;
+ title = `Import failed: ${sourceTitle}`;
+ } else {
+ iconName = icons.DOWNLOADING;
+ iconKind = kinds.DANGER;
+ title = 'Download failed';
+ }
+ }
+
+ return (
+
+
+ }
+ title={title}
+ body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
+ position={tooltipPositions.RIGHT}
+ />
+
+ );
+}
+
+QueueStatusCell.propTypes = {
+ sourceTitle: PropTypes.string.isRequired,
+ status: PropTypes.string.isRequired,
+ trackedDownloadStatus: PropTypes.string,
+ statusMessages: PropTypes.arrayOf(PropTypes.object),
+ errorMessage: PropTypes.string
+};
+
+export default QueueStatusCell;
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css
new file mode 100644
index 000000000..c9ef59ec1
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css
@@ -0,0 +1,3 @@
+.message {
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js
new file mode 100644
index 000000000..6c208ebbb
--- /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..d56472efb
--- /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..ead2bfcfa
--- /dev/null
+++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQueueStatus } from 'Store/Actions/queueActions';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ (state) => state.queue.status,
+ (state) => state.queue.options.includeUnknownMovieItems,
+ (app, status, includeUnknownMovieItems) => {
+ const {
+ count,
+ unknownCount
+ } = status.item;
+
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: status.isPopulated,
+ ...status.item,
+ count: includeUnknownMovieItems ? count : count - unknownCount
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQueueStatus
+};
+
+class QueueStatusConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.fetchQueueStatus();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.isConnected && prevProps.isReconnecting) {
+ this.props.fetchQueueStatus();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueStatusConnector.propTypes = {
+ isConnected: PropTypes.bool.isRequired,
+ isReconnecting: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchQueueStatus: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector);
diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css
new file mode 100644
index 000000000..eb58cf297
--- /dev/null
+++ b/frontend/src/Activity/Queue/TimeleftCell.css
@@ -0,0 +1,5 @@
+.timeleft {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js
new file mode 100644
index 000000000..c9515f172
--- /dev/null
+++ b/frontend/src/Activity/Queue/TimeleftCell.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatTime from 'Utilities/Date/formatTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './TimeleftCell.css';
+
+function TimeleftCell(props) {
+ const {
+ estimatedCompletionTime,
+ timeleft,
+ status,
+ size,
+ sizeleft,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (status === 'Delay') {
+ const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
+ const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (status === 'DownloadClientUnavailable') {
+ const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
+ const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (!timeleft) {
+ return (
+
+ -
+
+ );
+ }
+
+ const totalSize = formatBytes(size);
+ const remainingSize = formatBytes(sizeleft);
+
+ return (
+
+ {formatTimeSpan(timeleft)}
+
+ );
+}
+
+TimeleftCell.propTypes = {
+ estimatedCompletionTime: PropTypes.string,
+ timeleft: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default TimeleftCell;
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.css b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.css
new file mode 100644
index 000000000..0bf8b0e15
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.css
@@ -0,0 +1,54 @@
+.searchContainer {
+ display: flex;
+ margin-bottom: 10px;
+}
+
+.searchIconContainer {
+ width: 58px;
+ height: 46px;
+ border: 1px solid $inputBorderColor;
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: #edf1f2;
+ text-align: center;
+ line-height: 46px;
+}
+
+.searchInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ height: 46px;
+ border-radius: 0;
+ font-size: 18px;
+}
+
+.clearLookupButton {
+ border: 1px solid $inputBorderColor;
+ border-left: none;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+
+.message {
+ margin-top: 30px;
+ text-align: center;
+}
+
+.helpText {
+ margin-bottom: 10px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.noResults {
+ margin-bottom: 10px;
+ font-weight: 300;
+ font-size: 30px;
+}
+
+.searchResults {
+ margin-top: 30px;
+}
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js
new file mode 100644
index 000000000..e08ad2f0d
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js
@@ -0,0 +1,182 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import TextInput from 'Components/Form/TextInput';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import AddNewMovieSearchResultConnector from './AddNewMovieSearchResultConnector';
+import styles from './AddNewMovie.css';
+
+class AddNewMovie 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.onMovieLookupChange(term);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ term,
+ isFetching
+ } = this.props;
+
+ if (term && term !== prevProps.term) {
+ this.setState({
+ term,
+ isFetching: true
+ });
+ this.props.onMovieLookupChange(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.onMovieLookupChange(value);
+ } else {
+ this.props.onClearMovieLookup();
+ }
+ });
+ }
+
+ onClearMovieLookupPress = () => {
+ this.setState({ term: '' });
+ this.props.onClearMovieLookup();
+ }
+
+ //
+ // 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 TMDB ID or IMDB ID of a movie. eg. tmdb:71663
+
+
+ Why can't I find my movie?
+
+
+
+ }
+
+ {
+ !term &&
+
+
It's easy to add a new movie, just start typing the name the movie you want to add.
+
You can also search using TMDB ID of a movie. eg. tmdb:71663
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AddNewMovie.propTypes = {
+ term: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isAdding: PropTypes.bool.isRequired,
+ addError: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMovieLookupChange: PropTypes.func.isRequired,
+ onClearMovieLookup: PropTypes.func.isRequired
+};
+
+export default AddNewMovie;
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js
new file mode 100644
index 000000000..e4116a653
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import parseUrl from 'Utilities/String/parseUrl';
+import { lookupMovie, clearAddMovie } from 'Store/Actions/addMovieActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import AddNewMovie from './AddNewMovie';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addMovie,
+ (state) => state.routing.location,
+ (addMovie, location) => {
+ const { params } = parseUrl(location.search);
+
+ return {
+ term: params.term,
+ ...addMovie
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ lookupMovie,
+ clearAddMovie,
+ fetchRootFolders
+};
+
+class AddNewMovieConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._movieLookupTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ componentWillUnmount() {
+ if (this._movieLookupTimeout) {
+ clearTimeout(this._movieLookupTimeout);
+ }
+
+ this.props.clearAddMovie();
+ }
+
+ //
+ // Listeners
+
+ onMovieLookupChange = (term) => {
+ if (this._movieLookupTimeout) {
+ clearTimeout(this._movieLookupTimeout);
+ }
+
+ if (term.trim() === '') {
+ this.props.clearAddMovie();
+ } else {
+ this._movieLookupTimeout = setTimeout(() => {
+ this.props.lookupMovie({ term });
+ }, 300);
+ }
+ }
+
+ onClearMovieLookup = () => {
+ this.props.clearAddMovie();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ term,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+AddNewMovieConnector.propTypes = {
+ term: PropTypes.string,
+ lookupMovie: PropTypes.func.isRequired,
+ clearAddMovie: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewMovieConnector);
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModal.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModal.js
new file mode 100644
index 000000000..785f758ab
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNewMovieModalContentConnector from './AddNewMovieModalContentConnector';
+
+function AddNewMovieModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AddNewMovieModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNewMovieModal;
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.css b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.css
new file mode 100644
index 000000000..d1cffc210
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.css
@@ -0,0 +1,68 @@
+.container {
+ display: flex;
+}
+
+.year {
+ margin-left: 5px;
+ color: $disabledColor;
+}
+
+.poster {
+ flex: 0 0 170px;
+ margin-right: 20px;
+ height: 250px;
+}
+
+.info {
+ flex-grow: 1;
+}
+
+.overview {
+ margin-bottom: 30px;
+}
+
+.labelIcon {
+ margin-left: 8px;
+}
+
+.searchForMissingEpisodesLabelContainer {
+ display: flex;
+ margin-top: 2px;
+}
+
+.searchForMissingEpisodesLabel {
+ margin-right: 8px;
+ font-weight: normal;
+}
+
+.searchForMissingEpisodesContainer {
+ composes: container from 'Components/Form/CheckInput.css';
+
+ flex: 0 1 0;
+}
+
+.searchForMissingEpisodesInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
+
+.modalFooter {
+ composes: modalFooter from 'Components/Modal/ModalFooter.css';
+}
+
+.addButton {
+ @add-mixin truncate;
+ composes: button from 'Components/Link/SpinnerButton.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalFooter {
+ display: block;
+ text-align: center;
+ }
+
+ .addButton {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js
new file mode 100644
index 000000000..8e981bfe5
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js
@@ -0,0 +1,190 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds, inputTypes } from 'Helpers/Props';
+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 MoviePoster from 'Movie/MoviePoster';
+import styles from './AddNewMovieModalContent.css';
+
+class AddNewMovieModalContent 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) });
+ }
+
+ onAddMoviePress = () => {
+ this.props.onAddMoviePress(this.state.searchForMissingEpisodes);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ year,
+ overview,
+ images,
+ isAdding,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ tags,
+ isSmallScreen,
+ onModalClose,
+ onInputChange
+ } = this.props;
+
+ return (
+
+
+ {title}
+
+ {
+ !title.contains(year) && !!year &&
+ ({year})
+ }
+
+
+
+
+ {
+ !isSmallScreen &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+ Start search for missing movie
+
+
+
+
+
+
+ Add {title}
+
+
+
+ );
+ }
+}
+
+AddNewMovieModalContent.propTypes = {
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ overview: PropTypes.string,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isAdding: PropTypes.bool.isRequired,
+ addError: PropTypes.object,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ tags: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onAddMoviePress: PropTypes.func.isRequired
+};
+
+export default AddNewMovieModalContent;
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContentConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContentConnector.js
new file mode 100644
index 000000000..5f97abbda
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContentConnector.js
@@ -0,0 +1,97 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setAddMovieDefault, addMovie } from 'Store/Actions/addMovieActions';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import selectSettings from 'Store/Selectors/selectSettings';
+import AddNewMovieModalContent from './AddNewMovieModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addMovie,
+ createDimensionsSelector(),
+ (addMovieState, dimensions) => {
+ const {
+ isAdding,
+ addError,
+ defaults
+ } = addMovieState;
+
+ const {
+ settings,
+ validationErrors,
+ validationWarnings
+ } = selectSettings(defaults, {}, addError);
+
+ return {
+ isAdding,
+ addError,
+ isSmallScreen: dimensions.isSmallScreen,
+ validationErrors,
+ validationWarnings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setAddMovieDefault,
+ addMovie
+};
+
+class AddNewMovieModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setAddMovieDefault({ [name]: value });
+ }
+
+ onAddMoviePress = (searchForMissingEpisodes) => {
+ const {
+ tmdbId,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ tags
+ } = this.props;
+
+ this.props.addMovie({
+ tmdbId,
+ rootFolderPath: rootFolderPath.value,
+ monitor: monitor.value,
+ qualityProfileId: qualityProfileId.value,
+ tags: tags.value,
+ searchForMissingEpisodes
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNewMovieModalContentConnector.propTypes = {
+ tmdbId: PropTypes.number.isRequired,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ tags: PropTypes.object.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ setAddMovieDefault: PropTypes.func.isRequired,
+ addMovie: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewMovieModalContentConnector);
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.css b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.css
new file mode 100644
index 000000000..38ccffb4d
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.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/AddMovie/AddNewMovie/AddNewMovieSearchResult.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js
new file mode 100644
index 000000000..186459e95
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js
@@ -0,0 +1,162 @@
+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 MoviePoster from 'Movie/MoviePoster';
+import AddNewMovieModal from './AddNewMovieModal';
+import styles from './AddNewMovieSearchResult.css';
+
+class AddNewMovieSearchResult extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isNewAddMovieModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.isExistingMovie && this.props.isExistingMovie) {
+ this.onAddSerisModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isNewAddMovieModalOpen: true });
+ }
+
+ onAddSerisModalClose = () => {
+ this.setState({ isNewAddMovieModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ tmdbId,
+ title,
+ titleSlug,
+ year,
+ studio,
+ status,
+ overview,
+ ratings,
+ images,
+ isExistingMovie,
+ isSmallScreen
+ } = this.props;
+ const {
+ isNewAddMovieModalOpen
+ } = this.state;
+
+ const linkProps = isExistingMovie ? { to: `/movie/${titleSlug}` } : { onPress: this.onPress };
+
+ return (
+
+
+ {
+ isSmallScreen ?
+ null :
+
+ }
+
+
+
+ {title}
+
+ {
+ !title.contains(year) && !!year &&
+ ({year})
+ }
+
+ {
+ isExistingMovie &&
+
+ }
+
+
+
+
+
+
+
+ {
+ !!studio &&
+
+ {studio}
+
+ }
+
+ {
+ status === 'ended' &&
+
+ Ended
+
+ }
+
+
+
+ {overview}
+
+
+
+
+
+
+ );
+ }
+}
+
+AddNewMovieSearchResult.propTypes = {
+ tmdbId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ studio: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ ratings: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isExistingMovie: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired
+};
+
+export default AddNewMovieSearchResult;
diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js
new file mode 100644
index 000000000..fccbe6120
--- /dev/null
+++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingMovieSelector from 'Store/Selectors/createExistingMovieSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import AddNewMovieSearchResult from './AddNewMovieSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingMovieSelector(),
+ createDimensionsSelector(),
+ (isExistingMovie, dimensions) => {
+ return {
+ isExistingMovie,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(AddNewMovieSearchResult);
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js
new file mode 100644
index 000000000..b4617604b
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js
@@ -0,0 +1,169 @@
+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 ImportMovieTableConnector from './ImportMovieTableConnector';
+import ImportMovieFooterConnector from './ImportMovieFooterConnector';
+
+class ImportMovie 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
+ } = 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 &&
+
+ }
+
+ );
+ }
+}
+
+ImportMovie.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),
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired
+};
+
+ImportMovie.defaultProps = {
+ unmappedFolders: []
+};
+
+export default ImportMovie;
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js
new file mode 100644
index 000000000..5b2000cc1
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js
@@ -0,0 +1,152 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setImportMovieValue, importMovie, clearImportMovie } from 'Store/Actions/importMovieActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { setAddMovieDefault } from 'Store/Actions/addMovieActions';
+import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
+import ImportMovie from './ImportMovie';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ (state) => state.rootFolders,
+ (state) => state.addMovie,
+ (state) => state.importMovie,
+ (state) => state.settings.qualityProfiles,
+ (
+ match,
+ rootFolders,
+ addMovie,
+ importMovieState,
+ qualityProfiles,
+ ) => {
+ const {
+ isFetching: rootFoldersFetching,
+ isPopulated: rootFoldersPopulated,
+ error: rootFoldersError,
+ items
+ } = rootFolders;
+
+ const rootFolderId = parseInt(match.params.rootFolderId);
+
+ const result = {
+ rootFolderId,
+ rootFoldersFetching,
+ rootFoldersPopulated,
+ rootFoldersError,
+ qualityProfiles: qualityProfiles.items,
+ defaultQualityProfileId: addMovie.defaults.qualityProfileId
+ };
+
+ if (items.length) {
+ const rootFolder = _.find(items, { id: rootFolderId });
+
+ return {
+ ...result,
+ ...rootFolder,
+ items: importMovieState.items
+ };
+ }
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetImportMovieValue: setImportMovieValue,
+ dispatchImportMovie: importMovie,
+ dispatchClearImportMovie: clearImportMovie,
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchSetAddSeriesDefault: setAddMovieDefault
+};
+
+class ImportMovieConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ qualityProfiles,
+ defaultQualityProfileId,
+ dispatchFetchRootFolders,
+ dispatchSetAddSeriesDefault
+ } = this.props;
+
+ if (!this.props.rootFoldersPopulated) {
+ dispatchFetchRootFolders();
+ }
+
+ let setDefaults = false;
+ const setDefaultPayload = {};
+
+ if (
+ !defaultQualityProfileId ||
+ !qualityProfiles.some((p) => p.id === defaultQualityProfileId)
+ ) {
+ setDefaults = true;
+ setDefaultPayload.qualityProfileId = qualityProfiles[0].id;
+ }
+
+ if (setDefaults) {
+ dispatchSetAddSeriesDefault(setDefaultPayload);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearImportMovie();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (ids, name, value) => {
+ this.props.dispatchSetAddSeriesDefault({ [name]: value });
+
+ ids.forEach((id) => {
+ this.props.dispatchSetImportMovieValue({
+ id,
+ [name]: value
+ });
+ });
+ }
+
+ onImportPress = (ids) => {
+ this.props.dispatchImportMovie({ ids });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+const routeMatchShape = createRouteMatchShape({
+ rootFolderId: PropTypes.string.isRequired
+});
+
+ImportMovieConnector.propTypes = {
+ match: routeMatchShape.isRequired,
+ rootFoldersPopulated: PropTypes.bool.isRequired,
+ qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ defaultQualityProfileId: PropTypes.number.isRequired,
+ dispatchSetImportMovieValue: PropTypes.func.isRequired,
+ dispatchImportMovie: PropTypes.func.isRequired,
+ dispatchClearImportMovie: PropTypes.func.isRequired,
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchSetAddSeriesDefault: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieConnector);
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css
new file mode 100644
index 000000000..0a61ca509
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css
@@ -0,0 +1,33 @@
+.inputContainer {
+ margin-right: 20px;
+ min-width: 150px;
+}
+
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.importButtonContainer {
+ display: flex;
+ align-items: center;
+}
+
+.importButton {
+ composes: button from 'Components/Link/SpinnerButton.css';
+
+ height: 35px;
+}
+
+.loadingButton {
+ composes: importButton;
+
+ margin-left: 10px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin: 0 10px 0 12px;
+ text-align: left;
+}
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js
new file mode 100644
index 000000000..4c7eb7352
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js
@@ -0,0 +1,184 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+// import CheckInput from 'Components/Form/CheckInput';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import styles from './ImportMovieFooter.css';
+
+const MIXED = 'mixed';
+
+class ImportMovieFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ defaultMonitor,
+ defaultQualityProfileId
+ } = props;
+
+ this.state = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ defaultMonitor,
+ defaultQualityProfileId,
+ isMonitorMixed,
+ isQualityProfileIdMixed
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId
+ } = 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 (!_.isEmpty(newState)) {
+ this.setState(newState);
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ this.props.onInputChange({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedCount,
+ isImporting,
+ isLookingUpMovie,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ onImportPress,
+ onCancelLookupPress
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId
+ } = this.state;
+
+ return (
+
+
+
+
+
+ Quality Profile
+
+
+
+
+
+
+
+
+
+
+
+
+ Import {selectedCount} {selectedCount > 1 ? 'Movies' : 'Movie'}
+
+
+ {
+ isLookingUpMovie &&
+
+ Cancel Processing
+
+ }
+
+ {
+ isLookingUpMovie &&
+
+ }
+
+ {
+ isLookingUpMovie &&
+ 'Processing Folders'
+ }
+
+
+
+ );
+ }
+}
+
+ImportMovieFooter.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ isImporting: PropTypes.bool.isRequired,
+ isLookingUpMovie: PropTypes.bool.isRequired,
+ defaultMonitor: PropTypes.string.isRequired,
+ defaultQualityProfileId: PropTypes.number,
+ isMonitorMixed: PropTypes.bool.isRequired,
+ isQualityProfileIdMixed: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired,
+ onCancelLookupPress: PropTypes.func.isRequired
+};
+
+export default ImportMovieFooter;
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooterConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooterConnector.js
new file mode 100644
index 000000000..28cb4ea59
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooterConnector.js
@@ -0,0 +1,50 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { cancelLookupMovie } from 'Store/Actions/importMovieActions';
+import ImportMovieFooter from './ImportMovieFooter';
+
+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.addMovie,
+ (state) => state.importMovie,
+ (state, { selectedIds }) => selectedIds,
+ (addMovie, importMovie, selectedIds) => {
+ const {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId
+ } = addMovie.defaults;
+
+ const {
+ isLookingUpMovie,
+ isImporting,
+ items
+ } = importMovie;
+
+ const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
+ const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
+
+ return {
+ selectedCount: selectedIds.length,
+ isLookingUpMovie,
+ isImporting,
+ defaultMonitor,
+ defaultQualityProfileId,
+ isMonitorMixed,
+ isQualityProfileIdMixed
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onCancelLookupPress: cancelLookupMovie
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieFooter);
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.css
new file mode 100644
index 000000000..a80a35890
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.css
@@ -0,0 +1,30 @@
+.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 {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.movie {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
+
+.detailsIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.js
new file mode 100644
index 000000000..7b59b409f
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
+import styles from './ImportMovieHeader.css';
+
+function ImportMovieHeader(props) {
+ const {
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ return (
+
+
+
+
+ Folder
+
+
+
+ Monitor
+
+
+
+ Quality Profile
+
+
+
+ Movie
+
+
+ );
+}
+
+ImportMovieHeader.propTypes = {
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default ImportMovieHeader;
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.css
new file mode 100644
index 000000000..5dd1c9fef
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.css
@@ -0,0 +1,31 @@
+.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 {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.series {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.js
new file mode 100644
index 000000000..0839432ff
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.js
@@ -0,0 +1,84 @@
+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 ImportMovieSelectMovieConnector from './SelectMovie/ImportMovieSelectMovieConnector';
+import styles from './ImportMovieRow.css';
+
+function ImportMovieRow(props) {
+ const {
+ style,
+ id,
+ monitor,
+ qualityProfileId,
+ selectedMovie,
+ isExistingMovie,
+ isSelected,
+ onSelectedChange,
+ onInputChange
+ } = props;
+
+ return (
+
+
+
+
+ {id}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+ImportMovieRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ selectedMovie: PropTypes.object,
+ isExistingMovie: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ queued: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+ImportMovieRow.defaultsProps = {
+ items: []
+};
+
+export default ImportMovieRow;
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRowConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRowConnector.js
new file mode 100644
index 000000000..b134b8ffd
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRowConnector.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 { queueLookupMovie, setImportMovieValue } from 'Store/Actions/importMovieActions';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import ImportMovieRow from './ImportMovieRow';
+
+function createImportMovieItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.importMovie.items,
+ (id, items) => {
+ return _.find(items, { id }) || {};
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createImportMovieItemSelector(),
+ createAllMoviesSelector(),
+ (item, movies) => {
+ const selectedMovie = item && item.selectedMovie;
+ const isExistingMovie = !!selectedMovie && _.some(movies, { tmdbId: selectedMovie.tmdbId });
+
+ return {
+ ...item,
+ isExistingMovie
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ queueLookupMovie,
+ setImportMovieValue
+};
+
+class ImportMovieRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setImportMovieValue({
+ 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
+ } = this.props;
+
+ if (!items || !monitor) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+ImportMovieRowConnector.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.object),
+ queueLookupMovie: PropTypes.func.isRequired,
+ setImportMovieValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieRowConnector);
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieSelected.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieSelected.css
new file mode 100644
index 000000000..efc6dccb3
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieSelected.css
@@ -0,0 +1,3 @@
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+}
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTable.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTable.js
new file mode 100644
index 000000000..eb886d0e4
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTable.js
@@ -0,0 +1,183 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import VirtualTable from 'Components/Table/VirtualTable';
+import ImportMovieHeader from './ImportMovieHeader';
+import ImportMovieRowConnector from './ImportMovieRowConnector';
+
+class ImportMovieTable extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ unmappedFolders,
+ defaultMonitor,
+ defaultQualityProfileId,
+ onMovieLookup,
+ onSetImportMovieValue
+ } = this.props;
+
+ const values = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId
+ };
+
+ unmappedFolders.forEach((unmappedFolder) => {
+ const id = unmappedFolder.name;
+
+ onMovieLookup(id, unmappedFolder.path);
+
+ onSetImportMovieValue({
+ 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 selectedMovie = item.selectedMovie;
+ const isSelected = selectedState[id];
+
+ const isExistingMovie = !!selectedMovie &&
+ _.some(prevProps.allMovies, { tmdbId: selectedMovie.tmdbId });
+
+ // Props doesn't have a selected series or
+ // the selected series is an existing series.
+ if ((!selectedMovie && prevItem.selectedMovie) || (isExistingMovie && !prevItem.selectedMovie)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // State is selected, but a series isn't selected or
+ // the selected series is an existing series.
+ if (isSelected && (!selectedMovie || isExistingMovie)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // A series is being selected that wasn't previously selected.
+ if (selectedMovie && selectedMovie !== prevItem.selectedMovie) {
+ onSelectedChange({ id, value: true });
+
+ return;
+ }
+ });
+ }
+
+ //
+ // Control
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ rootFolderId,
+ items,
+ selectedState,
+ onSelectedChange
+ } = this.props;
+
+ const item = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ allSelected,
+ allUnselected,
+ isSmallScreen,
+ contentBody,
+ scrollTop,
+ selectedState,
+ onSelectAllChange,
+ onScroll
+ } = this.props;
+
+ if (!items.length) {
+ return null;
+ }
+
+ return (
+
+ }
+ selectedState={selectedState}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+ImportMovieTable.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object),
+ defaultMonitor: PropTypes.string.isRequired,
+ defaultQualityProfileId: PropTypes.number,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ selectedState: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ allMovies: PropTypes.arrayOf(PropTypes.object),
+ contentBody: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired,
+ onSelectedChange: PropTypes.func.isRequired,
+ onRemoveSelectedStateItem: PropTypes.func.isRequired,
+ onMovieLookup: PropTypes.func.isRequired,
+ onSetImportMovieValue: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ImportMovieTable;
diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTableConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTableConnector.js
new file mode 100644
index 000000000..42499c703
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTableConnector.js
@@ -0,0 +1,41 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { queueLookupMovie, setImportMovieValue } from 'Store/Actions/importMovieActions';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import ImportMovieTable from './ImportMovieTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addMovie,
+ (state) => state.importMovie,
+ (state) => state.app.dimensions,
+ createAllMoviesSelector(),
+ (addMovie, importMovie, dimensions, allMovies) => {
+ return {
+ defaultMonitor: addMovie.defaults.monitor,
+ defaultQualityProfileId: addMovie.defaults.qualityProfileId,
+ items: importMovie.items,
+ isSmallScreen: dimensions.isSmallScreen,
+ allMovies
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onMovieLookup(name, path) {
+ dispatch(queueLookupMovie({
+ name,
+ path,
+ term: name
+ }));
+ },
+
+ onSetImportMovieValue(values) {
+ dispatch(setImportMovieValue(values));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ImportMovieTable);
diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.css b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.css
new file mode 100644
index 000000000..a862c117c
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.css
@@ -0,0 +1,8 @@
+.series {
+ padding: 10px 20px;
+ width: 100%;
+
+ &:hover {
+ background-color: $menuItemHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.js
new file mode 100644
index 000000000..0545df7cc
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import ImportMovieTitle from './ImportMovieTitle';
+import styles from './ImportMovieSearchResult.css';
+
+class ImportMovieSearchResult extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.tmdbId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ year,
+ studio,
+ isExistingMovie
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+ImportMovieSearchResult.propTypes = {
+ tmdbId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ studio: PropTypes.string,
+ isExistingMovie: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default ImportMovieSearchResult;
diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResultConnector.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResultConnector.js
new file mode 100644
index 000000000..da5608403
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResultConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingMovieSelector from 'Store/Selectors/createExistingMovieSelector';
+import ImportMovieSearchResult from './ImportMovieSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingMovieSelector(),
+ (isExistingMovie) => {
+ return {
+ isExistingMovie
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ImportMovieSearchResult);
diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.css b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.css
new file mode 100644
index 000000000..1a7f4836e
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.css
@@ -0,0 +1,70 @@
+.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: input from 'Components/Form/TextInput.css';
+
+ border-radius: 0;
+}
diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js
new file mode 100644
index 000000000..c74e4126c
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js
@@ -0,0 +1,280 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputButton from 'Components/Form/FormInputButton';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import TextInput from 'Components/Form/TextInput';
+import ImportMovieSearchResultConnector from './ImportMovieSearchResultConnector';
+import ImportMovieTitle from './ImportMovieTitle';
+import styles from './ImportMovieSelectMovie.css';
+
+const tetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ],
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+};
+
+class ImportMovieSelectMovie extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._movieLookupTimeout = 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._movieLookupTimeout) {
+ clearTimeout(this._movieLookupTimeout);
+ }
+
+ this.setState({ term: value }, () => {
+ this._movieLookupTimeout = setTimeout(() => {
+ this.props.onSearchInputChange(value);
+ }, 200);
+ });
+ }
+
+ onRefreshPress = () => {
+ this.props.onSearchInputChange(this.state.term);
+ }
+
+ onMovieSelect = (tmdbId) => {
+ this.setState({ isOpen: false });
+
+ this.props.onMovieSelect(tmdbId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedMovie,
+ isExistingMovie,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ queued,
+ isLookingUpMovie
+ } = this.props;
+
+ const errorMessage = error &&
+ error.responseJSON &&
+ error.responseJSON.message;
+
+ return (
+
+
+ {
+ isLookingUpMovie && queued && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && selectedMovie && isExistingMovie &&
+
+ }
+
+ {
+ isPopulated && selectedMovie &&
+
+ }
+
+ {
+ isPopulated && !selectedMovie &&
+
+
+
+ No match found!
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+
+
+ Search failed, please try again later.
+
+ }
+
+
+
+
+
+
+ {
+ this.state.isOpen &&
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ );
+ }
+}
+
+ImportMovieSelectMovie.propTypes = {
+ id: PropTypes.string.isRequired,
+ selectedMovie: PropTypes.object,
+ isExistingMovie: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ queued: PropTypes.bool.isRequired,
+ isLookingUpMovie: PropTypes.bool.isRequired,
+ onSearchInputChange: PropTypes.func.isRequired,
+ onMovieSelect: PropTypes.func.isRequired
+};
+
+ImportMovieSelectMovie.defaultProps = {
+ isFetching: true,
+ isPopulated: false,
+ items: [],
+ queued: true
+};
+
+export default ImportMovieSelectMovie;
diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovieConnector.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovieConnector.js
new file mode 100644
index 000000000..31c94f294
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovieConnector.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 { queueLookupMovie, setImportMovieValue } from 'Store/Actions/importMovieActions';
+import createImportMovieItemSelector from 'Store/Selectors/createImportMovieItemSelector';
+import ImportMovieSelectMovie from './ImportMovieSelectMovie';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.importMovie.isLookingUpMovie,
+ createImportMovieItemSelector(),
+ (isLookingUpMovie, item) => {
+ return {
+ isLookingUpMovie,
+ ...item
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ queueLookupMovie,
+ setImportMovieValue
+};
+
+class ImportMovieSelectMovieConnector extends Component {
+
+ //
+ // Listeners
+
+ onSearchInputChange = (term) => {
+ this.props.queueLookupMovie({
+ name: this.props.id,
+ term,
+ topOfQueue: true
+ });
+ }
+
+ onMovieSelect = (tmdbId) => {
+ const {
+ id,
+ items
+ } = this.props;
+
+ this.props.setImportMovieValue({
+ id,
+ selectedMovie: _.find(items, { tmdbId })
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportMovieSelectMovieConnector.propTypes = {
+ id: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ selectedMovie: PropTypes.object,
+ isSelected: PropTypes.bool,
+ queueLookupMovie: PropTypes.func.isRequired,
+ setImportMovieValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieSelectMovieConnector);
diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.css b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.css
new file mode 100644
index 000000000..f6ae0f4e6
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.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/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.js
new file mode 100644
index 000000000..fc5ba5b75
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.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 './ImportMovieTitle.css';
+
+function ImportMovieTitle(props) {
+ const {
+ title,
+ year,
+ studio,
+ isExistingMovie
+ } = props;
+
+ return (
+
+
+ {title}
+
+ {
+ !title.contains(year) &&
+ ({year})
+ }
+
+
+ {
+ !!studio &&
+
{studio}
+ }
+
+ {
+ isExistingMovie &&
+
+ Existing
+
+ }
+
+ );
+}
+
+ImportMovieTitle.propTypes = {
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ studio: PropTypes.string,
+ isExistingMovie: PropTypes.bool.isRequired
+};
+
+export default ImportMovieTitle;
diff --git a/frontend/src/AddMovie/ImportMovie/ImportMovies.js b/frontend/src/AddMovie/ImportMovie/ImportMovies.js
new file mode 100644
index 000000000..b8d1a923f
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/ImportMovies.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import ImportMovieSelectFolderConnector from 'AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector';
+import ImportMovieConnector from 'AddMovie/ImportMovie/Import/ImportMovieConnector';
+
+class ImportMovies extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default ImportMovies;
diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.css b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.css
new file mode 100644
index 000000000..d9c5ccb01
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.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/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.js
new file mode 100644
index 000000000..7730e3b43
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './ImportMovieRootFolderRow.css';
+
+function ImportMovieRootFolderRow(props) {
+ const {
+ id,
+ path,
+ freeSpace,
+ unmappedFolders,
+ onDeletePress
+ } = props;
+
+ const unmappedFoldersCount = unmappedFolders.length || '-';
+
+ return (
+
+
+
+ {path}
+
+
+
+
+ {formatBytes(freeSpace) || '-'}
+
+
+
+ {unmappedFoldersCount}
+
+
+
+
+
+
+ );
+}
+
+ImportMovieRootFolderRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number.isRequired,
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired
+};
+
+ImportMovieRootFolderRow.defaultProps = {
+ freeSpace: 0,
+ unmappedFolders: []
+};
+
+export default ImportMovieRootFolderRow;
diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRowConnector.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRowConnector.js
new file mode 100644
index 000000000..e25637472
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRowConnector.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 ImportMovieRootFolderRow from './ImportMovieRootFolderRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ () => {
+ return {
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteRootFolder
+};
+
+class ImportMovieRootFolderRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = () => {
+ this.props.deleteRootFolder({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportMovieRootFolderRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ deleteRootFolder: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieRootFolderRowConnector);
diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.css b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.css
new file mode 100644
index 000000000..030da96fb
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.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/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.js
new file mode 100644
index 000000000..32211ed19
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.js
@@ -0,0 +1,188 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import ImportMovieRootFolderRowConnector from './ImportMovieRootFolderRowConnector';
+import styles from './ImportMovieSelectFolder.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 ImportMovieSelectFolder extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNewRootFolderModalOpen: false
+ };
+ }
+
+ //
+ // Lifecycle
+
+ onAddNewRootFolderPress = () => {
+ this.setState({ isAddNewRootFolderModalOpen: true });
+ }
+
+ onNewRootFolderSelect = ({ value }) => {
+ this.props.onNewRootFolderSelect(value);
+ }
+
+ onAddRootFolderModalClose = () => {
+ this.setState({ isAddNewRootFolderModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isWindows,
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = this.props;
+
+ return (
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load root folders
+ }
+
+ {
+ !error && isPopulated &&
+
+
+ Import movies 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 Radarr to the folder containing all of your movies not a specific one. eg. "{isWindows ? 'C:\\movies' : '/movies'}" and not "{isWindows ? 'C:\\movies\\the matrix' : '/movies/the matrix'}"
+
+
+
+
+ {
+ items.length > 0 ?
+
+
+
+
+ {
+ items.map((rootFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+ Choose another folder
+
+
:
+
+
+
+
+ Start Import
+
+
+ }
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ImportMovieSelectFolder.propTypes = {
+ isWindows: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNewRootFolderSelect: PropTypes.func.isRequired,
+ onDeleteRootFolderPress: PropTypes.func.isRequired
+};
+
+export default ImportMovieSelectFolder;
diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector.js
new file mode 100644
index 000000000..a3d5b54d6
--- /dev/null
+++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { push } from 'react-router-redux';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions';
+import ImportMovieSelectFolder from './ImportMovieSelectFolder';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ createSystemStatusSelector(),
+ (rootFolders, systemStatus) => {
+ return {
+ ...rootFolders,
+ isWindows: systemStatus.isWindows
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRootFolders,
+ addRootFolder,
+ deleteRootFolder,
+ push
+};
+
+class ImportMovieSelectFolderConnector 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.Radarr.urlBase}/add/import/${newRootFolders[0].id}`);
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onNewRootFolderSelect = (path) => {
+ this.props.addRootFolder({ path });
+ }
+
+ onDeleteRootFolderPress = (id) => {
+ this.props.deleteRootFolder({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportMovieSelectFolderConnector.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)(ImportMovieSelectFolderConnector);
diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js
new file mode 100644
index 000000000..d87c62827
--- /dev/null
+++ b/frontend/src/App/App.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import { Provider } from 'react-redux';
+import { ConnectedRouter } from 'react-router-redux';
+import PageConnector from 'Components/Page/PageConnector';
+import AppRoutes from './AppRoutes';
+
+function App({ store, history }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+App.propTypes = {
+ store: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired
+};
+
+export default App;
diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js
new file mode 100644
index 000000000..dd6bfbae1
--- /dev/null
+++ b/frontend/src/App/AppRoutes.js
@@ -0,0 +1,232 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Route, Redirect } from 'react-router-dom';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import NotFound from 'Components/NotFound';
+import Switch from 'Components/Router/Switch';
+import MovieIndexConnector from 'Movie/Index/MovieIndexConnector';
+import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector';
+import ImportMovies from 'AddMovie/ImportMovie/ImportMovies';
+import SeriesEditorConnector from 'Movie/Editor/SeriesEditorConnector';
+import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
+import CalendarPageConnector from 'Calendar/CalendarPageConnector';
+import HistoryConnector from 'Activity/History/HistoryConnector';
+import QueueConnector from 'Activity/Queue/QueueConnector';
+import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector';
+import Settings from 'Settings/Settings';
+import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
+import Profiles from 'Settings/Profiles/Profiles';
+import Quality from 'Settings/Quality/Quality';
+import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
+import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
+import NetImportSettingsConnector from 'Settings/NetImport/NetImportSettingsConnector';
+import NotificationSettings from 'Settings/Notifications/NotificationSettings';
+import MetadataSettings from 'Settings/Metadata/MetadataSettings';
+import TagSettings from 'Settings/Tags/TagSettings';
+import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
+import UISettingsConnector from 'Settings/UI/UISettingsConnector';
+import Status from 'System/Status/Status';
+import Tasks from 'System/Tasks/Tasks';
+import BackupsConnector from 'System/Backup/BackupsConnector';
+import UpdatesConnector from 'System/Updates/UpdatesConnector';
+import LogsTableConnector from 'System/Events/LogsTableConnector';
+import Logs from 'System/Logs/Logs';
+
+function AppRoutes(props) {
+ const {
+ app
+ } = props;
+
+ return (
+
+ {/*
+ Movies
+ */}
+
+
+
+ {
+ window.Radarr.urlBase &&
+ {
+ return (
+
+ );
+ }}
+ />
+ }
+
+
+
+
+
+
+
+
+
+ {/*
+ Calendar
+ */}
+
+
+
+ {/*
+ Activity
+ */}
+
+
+
+
+
+
+
+ {/*
+ Settings
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ System
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not Found
+ */}
+
+
+
+ );
+}
+
+AppRoutes.propTypes = {
+ app: PropTypes.func.isRequired
+};
+
+export default AppRoutes;
diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js
new file mode 100644
index 000000000..abc7f8832
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModal.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
+
+function AppUpdatedModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AppUpdatedModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js
new file mode 100644
index 000000000..a21afbc5a
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import AppUpdatedModal from './AppUpdatedModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(AppUpdatedModal);
diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css
new file mode 100644
index 000000000..37b89c9be
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.css
@@ -0,0 +1,15 @@
+.version {
+ margin: 0 3px;
+ font-weight: bold;
+}
+
+.maintenance {
+ margin-top: 20px;
+}
+
+.changes {
+ margin-top: 20px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+ font-size: 18px;
+}
diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js
new file mode 100644
index 000000000..d22eb98b5
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import UpdateChanges from 'System/Updates/UpdateChanges';
+import styles from './AppUpdatedModalContent.css';
+
+function AppUpdatedModalContent(props) {
+ const {
+ version,
+ isPopulated,
+ error,
+ items,
+ onSeeChangesPress,
+ onModalClose
+ } = props;
+
+ const update = items[0];
+
+ return (
+
+
+ Radarr Updated
+
+
+
+
+ Version {version} of Radarr has been installed, in order to get the latest changes you'll need to reload Radarr.
+
+
+ {
+ isPopulated && !error && !!update &&
+
+ {
+ !update.changes &&
+
Maintenance release
+ }
+
+ {
+ !!update.changes &&
+
+
+ What's new?
+
+
+
+
+
+
+ }
+
+ }
+
+ {
+ !isPopulated && !error &&
+
+ }
+
+
+
+
+ Recent Changes
+
+
+
+ Reload
+
+
+
+ );
+}
+
+AppUpdatedModalContent.propTypes = {
+ version: PropTypes.string.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSeeChangesPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AppUpdatedModalContent;
diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js
new file mode 100644
index 000000000..325a1aa95
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContentConnector.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import AppUpdatedModalContent from './AppUpdatedModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.version,
+ (state) => state.system.updates,
+ (version, updates) => {
+ const {
+ isPopulated,
+ error,
+ items
+ } = updates;
+
+ return {
+ version,
+ isPopulated,
+ error,
+ items
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchUpdates() {
+ dispatch(fetchUpdates());
+ },
+
+ onSeeChangesPress() {
+ window.location = `${window.Radarr.urlBase}/system/updates`;
+ }
+ };
+}
+
+class AppUpdatedModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchUpdates();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.version !== this.props.version) {
+ this.props.dispatchFetchUpdates();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchUpdates,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+AppUpdatedModalContentConnector.propTypes = {
+ version: PropTypes.string.isRequired,
+ dispatchFetchUpdates: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);
diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.js
new file mode 100644
index 000000000..de98ac8fb
--- /dev/null
+++ b/frontend/src/App/ColorImpairedContext.js
@@ -0,0 +1,6 @@
+import React from 'react';
+
+const ColorImpairedContext = React.createContext(false);
+export const ColorImpairedConsumer = ColorImpairedContext.Consumer;
+
+export default ColorImpairedContext;
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..063b3f47f
--- /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
+
+
+
+
+ Radarr has lost it's connection to the backend and will need to be reloaded to restore functionality.
+
+
+
+ Radarr will try to connect automatically, or you can click reload below.
+
+
+
+
+ Reload
+
+
+
+
+ );
+}
+
+ConnectionLostModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js
new file mode 100644
index 000000000..8ab8e3cd0
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import ConnectionLostModal from './ConnectionLostModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);
diff --git a/frontend/src/Calendar/Agenda/Agenda.css b/frontend/src/Calendar/Agenda/Agenda.css
new file mode 100644
index 000000000..0304d9db5
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.css
@@ -0,0 +1,3 @@
+.agenda {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js
new file mode 100644
index 000000000..89472301d
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.js
@@ -0,0 +1,38 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import AgendaEventConnector from './AgendaEventConnector';
+import styles from './Agenda.css';
+
+function Agenda(props) {
+ const {
+ items
+ } = props;
+
+ return (
+
+ {
+ items.map((item, index) => {
+ const momentDate = moment(item.airDateUtc);
+ const showDate = index === 0 ||
+ !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
+
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+Agenda.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Agenda;
diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js
new file mode 100644
index 000000000..b6f238873
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaConnector.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import Agenda from './Agenda';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Agenda);
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css
new file mode 100644
index 000000000..bf0df3f2c
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css
@@ -0,0 +1,117 @@
+.event {
+ display: flex;
+ overflow-x: hidden;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ font-size: 14px;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.eventWrapper {
+ display: flex;
+ flex: 1 0 1px;
+ overflow-x: hidden;
+ padding-left: 6px;
+ border-left-width: 4px;
+ border-left-style: solid;
+}
+
+.date {
+ flex: 0 0 250px;
+ font-weight: bold;
+}
+
+.time {
+ flex: 0 0 120px;
+ margin-right: 10px;
+ border: none !important;
+}
+
+.seriesTitle,
+.episodeTitle {
+ @add-mixin truncate;
+
+ flex: 0 1 300px;
+ margin-right: 10px;
+}
+
+.episodeTitle {
+ flex: 1 1 1px;
+}
+
+.seasonEpisodeNumber {
+ flex: 0 0 100px;
+}
+
+.episodeSeparator {
+ display: none;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from 'Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
+}
+
+.onAir {
+ composes: onAir from 'Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from 'Calendar/Events/CalendarEvent.css';
+}
+
+.premiere {
+ composes: premiere from 'Calendar/Events/CalendarEvent.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .event {
+ flex-direction: column;
+ }
+
+ .eventWrapper {
+ display: block;
+ flex: 0 0 auto;
+ }
+
+ .date {
+ margin-left: 10px;
+ }
+
+ .date,
+ .time,
+ .seriesTitle {
+ flex: 0 0 100%;
+ }
+
+ .seasonEpisodeNumber {
+ flex: 0 0 auto;
+ }
+
+ .episodeSeparator {
+ display: inline-block;
+ margin: 0 5px;
+ }
+}
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js
new file mode 100644
index 000000000..22b33532e
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.js
@@ -0,0 +1,141 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
+import MovieTitleLink from 'Movie/MovieTitleLink';
+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 {
+ movieFile,
+ title,
+ titleSlug,
+ inCinemas,
+ monitored,
+ hasFile,
+ grabbed,
+ queueItem,
+ showDate,
+ showCutoffUnmetIcon,
+ longDateFormat,
+ colorImpairedMode
+ } = this.props;
+
+ const startTime = moment(inCinemas);
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = monitored;
+ const statusStyle = getStatusStyle(hasFile, downloading, startTime, isMonitored);
+
+ return (
+
+
+
+ {
+ showDate &&
+ startTime.format(longDateFormat)
+ }
+
+
+
+
+
+
+
+ {
+ !!queueItem &&
+
+
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+ {
+ showCutoffUnmetIcon &&
+ !!movieFile &&
+ movieFile.qualityCutoffNotMet &&
+
+ }
+
+
+
+ );
+ }
+}
+
+AgendaEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ episodeFile: PropTypes.object,
+ title: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ inCinemas: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ showDate: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default AgendaEvent;
diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
new file mode 100644
index 000000000..ec6cae394
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
@@ -0,0 +1,30 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import AgendaEvent from './AgendaEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createMovieSelector(),
+ createMovieFileSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (calendarOptions, series, episodeFile, queueItem, uiSettings) => {
+ return {
+ series,
+ episodeFile,
+ queueItem,
+ ...calendarOptions,
+ timeFormat: uiSettings.timeFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(AgendaEvent);
diff --git a/frontend/src/Calendar/Calendar.css b/frontend/src/Calendar/Calendar.css
new file mode 100644
index 000000000..37e6ff618
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.css
@@ -0,0 +1,8 @@
+.calendar {
+ flex-grow: 1;
+ width: 100%;
+}
+
+.calendarContent {
+ width: 100%;
+}
diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js
new file mode 100644
index 000000000..6ceb1f3bb
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import * as calendarViews from './calendarViews';
+import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
+import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
+import CalendarDaysConnector from './Day/CalendarDaysConnector';
+import AgendaConnector from './Agenda/AgendaConnector';
+import styles from './Calendar.css';
+
+class Calendar extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ view
+ } = this.props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && view === calendarViews.AGENDA &&
+
+ }
+
+ {
+ !error && isPopulated && view !== calendarViews.AGENDA &&
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+Calendar.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ view: PropTypes.string.isRequired
+};
+
+export default Calendar;
diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js
new file mode 100644
index 000000000..ef0f40b86
--- /dev/null
+++ b/frontend/src/Calendar/CalendarConnector.js
@@ -0,0 +1,183 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import * as calendarActions from 'Store/Actions/calendarActions';
+import { fetchMovieFiles, clearMovieFiles } from 'Store/Actions/movieFileActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as commandNames from 'Commands/commandNames';
+import Calendar from './Calendar';
+
+const UPDATE_DELAY = 3600000; // 1 hour
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (state) => state.settings.ui.item.firstDayOfWeek,
+ createCommandExecutingSelector(commandNames.REFRESH_MOVIE),
+ (calendar, firstDayOfWeek, isRefreshingMovie) => {
+ return {
+ ...calendar,
+ isRefreshingMovie,
+ firstDayOfWeek
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...calendarActions,
+ fetchMovieFiles,
+ clearMovieFiles,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class CalendarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate);
+ this.props.gotoCalendarToday();
+ this.scheduleUpdate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ time,
+ view,
+ isRefreshingMovie,
+ firstDayOfWeek
+ } = this.props;
+
+ if (hasDifferentItems(prevProps.items, items)) {
+ const episodeIds = selectUniqueIds(items, 'id');
+ const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
+
+ if (items.length) {
+ this.props.fetchQueueDetails({ episodeIds });
+ }
+
+ if (episodeFileIds.length) {
+ this.props.fetchMovieFiles({ episodeFileIds });
+ }
+ }
+
+ if (prevProps.time !== time) {
+ this.scheduleUpdate();
+ }
+
+ if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
+ this.props.fetchCalendar({ time, view });
+ }
+
+ if (prevProps.isRefreshingMovie && !isRefreshingMovie) {
+ this.props.fetchCalendar({ time, view });
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCalendar();
+ this.props.clearQueueDetails();
+ this.props.clearMovieFiles();
+ this.clearUpdateTimeout();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ const {
+ time,
+ view
+ } = this.props;
+
+ this.props.fetchQueueDetails({ time, view });
+ this.props.fetchCalendar({ time, view });
+ }
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+
+ this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
+ }
+
+ clearUpdateTimeout = () => {
+ if (this.updateTimeoutId) {
+ clearTimeout(this.updateTimeoutId);
+ }
+ }
+
+ updateCalendar = () => {
+ this.props.gotoCalendarToday();
+ this.scheduleUpdate();
+ }
+
+ //
+ // Listeners
+
+ onCalendarViewChange = (view) => {
+ this.props.setCalendarView({ view });
+ }
+
+ onTodayPress = () => {
+ this.props.gotoCalendarToday();
+ }
+
+ onPreviousPress = () => {
+ this.props.gotoCalendarPreviousRange();
+ }
+
+ onNextPress = () => {
+ this.props.gotoCalendarNextRange();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarConnector.propTypes = {
+ time: PropTypes.string,
+ view: PropTypes.string.isRequired,
+ firstDayOfWeek: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isRefreshingMovie: PropTypes.bool.isRequired,
+ setCalendarView: PropTypes.func.isRequired,
+ gotoCalendarToday: PropTypes.func.isRequired,
+ gotoCalendarPreviousRange: PropTypes.func.isRequired,
+ gotoCalendarNextRange: PropTypes.func.isRequired,
+ clearCalendar: PropTypes.func.isRequired,
+ fetchCalendar: PropTypes.func.isRequired,
+ fetchMovieFiles: PropTypes.func.isRequired,
+ clearMovieFiles: 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..7cb12da2a
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.js
@@ -0,0 +1,152 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import Measure from 'Components/Measure';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import NoMovie from 'Movie/NoMovie';
+import CalendarLinkModal from './iCal/CalendarLinkModal';
+import CalendarOptionsModal from './Options/CalendarOptionsModal';
+import LegendConnector from './Legend/LegendConnector';
+import CalendarConnector from './CalendarConnector';
+import styles from './CalendarPage.css';
+
+const MINIMUM_DAY_WIDTH = 120;
+
+class CalendarPage extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isCalendarLinkModalOpen: false,
+ isOptionsModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
+
+ this.props.onDaysCountChange(days);
+ }
+
+ onGetCalendarLinkPress = () => {
+ this.setState({ isCalendarLinkModalOpen: true });
+ }
+
+ onGetCalendarLinkModalClose = () => {
+ this.setState({ isCalendarLinkModalOpen: false });
+ }
+
+ onOptionsPress = () => {
+ this.setState({ isOptionsModalOpen: true });
+ }
+
+ onOptionsModalClose = () => {
+ this.setState({ isOptionsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedFilterKey,
+ filters,
+ hasSeries,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ isCalendarLinkModalOpen,
+ isOptionsModalOpen
+ } = this.state;
+
+ const isMeasured = this.state.width > 0;
+ let PageComponent = 'div';
+
+ if (isMeasured) {
+ PageComponent = hasSeries ? CalendarConnector : NoMovie;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ hasSeries &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+CalendarPage.propTypes = {
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ hasSeries: PropTypes.bool.isRequired,
+ onDaysCountChange: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+export default CalendarPage;
diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js
new file mode 100644
index 000000000..276d7a5e2
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPageConnector.js
@@ -0,0 +1,36 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
+import createMovieCountSelector from 'Store/Selectors/createMovieCountSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarPage from './CalendarPage';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createMovieCountSelector(),
+ createUISettingsSelector(),
+ (calendar, seriesCount, uiSettings) => {
+ return {
+ selectedFilterKey: calendar.selectedFilterKey,
+ filters: calendar.filters,
+ colorImpairedMode: uiSettings.enableColorImpairedMode,
+ hasSeries: !!seriesCount
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDaysCountChange(dayCount) {
+ dispatch(setCalendarDaysCount({ dayCount }));
+ },
+
+ onFilterSelect(selectedFilterKey) {
+ dispatch(setCalendarFilter({ selectedFilterKey }));
+ }
+ };
+}
+
+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..faa45b28b
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.js
@@ -0,0 +1,74 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
+import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
+import styles from './CalendarDay.css';
+
+function CalendarDay(props) {
+ const {
+ date,
+ time,
+ isTodaysDate,
+ events,
+ view,
+ onEventModalOpenToggle
+ } = props;
+
+ return (
+
+ {
+ view === calendarViews.MONTH &&
+
+ {moment(date).date()}
+
+ }
+
+ {
+ events.map((event) => {
+ if (event.isGroup) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })
+ }
+
+
+ );
+}
+
+CalendarDay.propTypes = {
+ date: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ isTodaysDate: PropTypes.bool.isRequired,
+ events: PropTypes.arrayOf(PropTypes.object).isRequired,
+ view: PropTypes.string.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarDay;
diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js
new file mode 100644
index 000000000..8fd6cc5a1
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDayConnector.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import CalendarDay from './CalendarDay';
+
+function sort(items) {
+ return _.sortBy(items, (item) => {
+ if (item.isGroup) {
+ return moment(item.events[0].airDateUtc).unix();
+ }
+
+ return moment(item.airDateUtc).unix();
+ });
+}
+
+function createCalendarEventsConnector() {
+ return createSelector(
+ (state, { date }) => date,
+ (state) => state.calendar.items,
+ (state) => state.calendar.options.collapseMultipleEpisodes,
+ (date, items, collapseMultipleEpisodes) => {
+ const filtered = _.filter(items, (item) => {
+ return moment(date).isSame(moment(item.airDateUtc), 'day');
+ });
+
+ if (!collapseMultipleEpisodes) {
+ return sort(filtered);
+ }
+
+ const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`);
+ const grouped = [];
+
+ Object.keys(groupedObject).forEach((key) => {
+ const events = groupedObject[key];
+
+ if (events.length === 1) {
+ grouped.push(events[0]);
+ } else {
+ grouped.push({
+ isGroup: true,
+ seriesId: events[0].seriesId,
+ seasonNumber: events[0].seasonNumber,
+ episodeIds: events.map((event) => event.id),
+ events: _.sortBy(events, (item) => moment(item.airDateUtc).unix())
+ });
+ }
+ });
+
+ const sorted = sort(grouped);
+
+ return sorted;
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createCalendarEventsConnector(),
+ (calendar, events) => {
+ return {
+ time: calendar.time,
+ view: calendar.view,
+ events
+ };
+ }
+ );
+}
+
+class CalendarDayConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarDayConnector.propTypes = {
+ date: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(CalendarDayConnector);
diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css
new file mode 100644
index 000000000..22005e3e6
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.css
@@ -0,0 +1,14 @@
+.days {
+ display: flex;
+ border-right: 1px solid $borderColor;
+}
+
+.day,
+.week,
+.forecast {
+ flex-wrap: nowrap;
+}
+
+.month {
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js
new file mode 100644
index 000000000..0a1a36172
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.js
@@ -0,0 +1,164 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import isToday from 'Utilities/Date/isToday';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarDayConnector from './CalendarDayConnector';
+import styles from './CalendarDays.css';
+
+class CalendarDays extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._touchStart = null;
+
+ this.state = {
+ todaysDate: moment().startOf('day').toISOString(),
+ isEventModalOpen: false
+ };
+
+ this.updateTimeoutId = null;
+ }
+
+ // Lifecycle
+
+ componentDidMount() {
+ const view = this.props.view;
+
+ if (view === calendarViews.MONTH) {
+ this.scheduleUpdate();
+ }
+
+ window.addEventListener('touchstart', this.onTouchStart);
+ window.addEventListener('touchend', this.onTouchEnd);
+ window.addEventListener('touchcancel', this.onTouchCancel);
+ window.addEventListener('touchmove', this.onTouchMove);
+ }
+
+ componentWillUnmount() {
+ this.clearUpdateTimeout();
+
+ window.removeEventListener('touchstart', this.onTouchStart);
+ window.removeEventListener('touchend', this.onTouchEnd);
+ window.removeEventListener('touchcancel', this.onTouchCancel);
+ window.removeEventListener('touchmove', this.onTouchMove);
+ }
+
+ //
+ // Control
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+ const todaysDate = moment().startOf('day');
+ const diff = moment().diff(todaysDate.clone().add(1, 'day'));
+
+ this.setState({ todaysDate: todaysDate.toISOString() });
+
+ this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
+ }
+
+ clearUpdateTimeout = () => {
+ if (this.updateTimeoutId) {
+ clearTimeout(this.updateTimeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ onEventModalOpenToggle = (isEventModalOpen) => {
+ this.setState({ isEventModalOpen });
+ }
+
+ onTouchStart = (event) => {
+ const touches = event.touches;
+ const touchStart = touches[0].pageX;
+
+ if (touches.length !== 1) {
+ return;
+ }
+
+ if (
+ touchStart < 50 ||
+ this.props.isSidebarVisible ||
+ this.state.isEventModalOpen
+ ) {
+ return;
+ }
+
+ this._touchStart = touchStart;
+ }
+
+ onTouchEnd = (event) => {
+ const touches = event.changedTouches;
+ const currentTouch = touches[0].pageX;
+
+ if (!this._touchStart) {
+ return;
+ }
+
+ if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
+ this.props.onNavigatePrevious();
+ } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
+ this.props.onNavigateNext();
+ }
+
+ this._touchStart = null;
+ }
+
+ onTouchCancel = (event) => {
+ this._touchStart = null;
+ }
+
+ onTouchMove = (event) => {
+ if (!this._touchStart) {
+ return;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dates,
+ view
+ } = this.props;
+
+ return (
+
+ {
+ dates.map((date) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+CalendarDays.propTypes = {
+ dates: PropTypes.arrayOf(PropTypes.string).isRequired,
+ view: PropTypes.string.isRequired,
+ isSidebarVisible: PropTypes.bool.isRequired,
+ onNavigatePrevious: PropTypes.func.isRequired,
+ onNavigateNext: PropTypes.func.isRequired
+};
+
+export default CalendarDays;
diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js
new file mode 100644
index 000000000..3dea906a7
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions';
+import CalendarDays from './CalendarDays';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (state) => state.app.isSidebarVisible,
+ (calendar, isSidebarVisible) => {
+ return {
+ dates: calendar.dates,
+ view: calendar.view,
+ isSidebarVisible
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onNavigatePrevious: gotoCalendarPreviousRange,
+ onNavigateNext: gotoCalendarNextRange
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);
diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css
new file mode 100644
index 000000000..8c3552e55
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.css
@@ -0,0 +1,13 @@
+.dayOfWeek {
+ flex: 1 0 14.28%;
+ background-color: #e4eaec;
+ text-align: center;
+}
+
+.isSingleDay {
+ width: 100%;
+}
+
+.isToday {
+ background-color: $calendarTodayBackgroundColor;
+}
diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js
new file mode 100644
index 000000000..d97671522
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.js
@@ -0,0 +1,56 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import * as calendarViews from 'Calendar/calendarViews';
+import styles from './DayOfWeek.css';
+
+class DayOfWeek extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ date,
+ view,
+ isTodaysDate,
+ calendarWeekColumnHeader,
+ shortDateFormat,
+ showRelativeDates
+ } = this.props;
+
+ const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
+ const momentDate = moment(date);
+ let formatedDate = momentDate.format('dddd');
+
+ if (view === calendarViews.WEEK) {
+ formatedDate = momentDate.format(calendarWeekColumnHeader);
+ } else if (view === calendarViews.FORECAST) {
+ formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates);
+ }
+
+ return (
+
+ {formatedDate}
+
+ );
+ }
+}
+
+DayOfWeek.propTypes = {
+ date: PropTypes.string.isRequired,
+ view: PropTypes.string.isRequired,
+ isTodaysDate: PropTypes.bool.isRequired,
+ calendarWeekColumnHeader: PropTypes.string.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired
+};
+
+export default DayOfWeek;
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css b/frontend/src/Calendar/Day/DaysOfWeek.css
new file mode 100644
index 000000000..518664633
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeek.css
@@ -0,0 +1,4 @@
+.daysOfWeek {
+ display: flex;
+ margin-top: 10px;
+}
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js
new file mode 100644
index 000000000..a67777f7c
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeek.js
@@ -0,0 +1,97 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import DayOfWeek from './DayOfWeek';
+import * as calendarViews from 'Calendar/calendarViews';
+import styles from './DaysOfWeek.css';
+
+class DaysOfWeek extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ todaysDate: moment().startOf('day').toISOString()
+ };
+
+ this.updateTimeoutId = null;
+ }
+
+ // Lifecycle
+
+ componentDidMount() {
+ const view = this.props.view;
+
+ if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
+ this.scheduleUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ this.clearUpdateTimeout();
+ }
+
+ //
+ // Control
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+ const todaysDate = moment().startOf('day');
+ const diff = todaysDate.clone().add(1, 'day').diff(moment());
+
+ this.setState({
+ todaysDate: todaysDate.toISOString()
+ });
+
+ this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
+ }
+
+ clearUpdateTimeout = () => {
+ if (this.updateTimeoutId) {
+ clearTimeout(this.updateTimeoutId);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dates,
+ view,
+ ...otherProps
+ } = this.props;
+
+ if (view === calendarViews.AGENDA) {
+ return null;
+ }
+
+ return (
+
+ {
+ dates.map((date) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+DaysOfWeek.propTypes = {
+ dates: PropTypes.arrayOf(PropTypes.string),
+ view: PropTypes.string.isRequired
+};
+
+export default DaysOfWeek;
diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js
new file mode 100644
index 000000000..7f5cdef19
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeekConnector.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import DaysOfWeek from './DaysOfWeek';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createUISettingsSelector(),
+ (calendar, UiSettings) => {
+ return {
+ dates: calendar.dates.slice(0, 7),
+ view: calendar.view,
+ calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader,
+ shortDateFormat: UiSettings.shortDateFormat,
+ showRelativeDates: UiSettings.showRelativeDates
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(DaysOfWeek);
diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css
new file mode 100644
index 000000000..3352d5127
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.css
@@ -0,0 +1,78 @@
+.event {
+ overflow-x: hidden;
+ margin: 4px 2px;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 4px solid $borderColor;
+ font-size: 12px;
+}
+
+.info,
+.episodeInfo {
+ display: flex;
+}
+
+.seriesTitle,
+.episodeTitle {
+ @add-mixin truncate;
+
+ flex: 1 0 1px;
+ margin-right: 10px;
+}
+
+.seriesTitle {
+ color: #3a3f51;
+ font-size: 14px;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ border-left-color: $successColor !important;
+}
+
+.downloading {
+ border-left-color: $purple !important;
+}
+
+.unmonitored {
+ border-left-color: $gray !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.onAir {
+ border-left-color: $warningColor !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.missing {
+ border-left-color: $dangerColor !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.unaired {
+ border-left-color: $primaryColor !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js
new file mode 100644
index 000000000..fbc21b8cf
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.js
@@ -0,0 +1,135 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import CalendarEventQueueDetails from './CalendarEventQueueDetails';
+import MovieTitleLink from 'Movie/MovieTitleLink';
+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 {
+ movieFile,
+ inCinemas,
+ title,
+ titleSlug,
+ monitored,
+ hasFile,
+ grabbed,
+ queueItem,
+ showCutoffUnmetIcon,
+ colorImpairedMode
+ } = this.props;
+
+ const startTime = moment(inCinemas);
+ const isDownloading = !!(queueItem || grabbed);
+ const isMonitored = monitored;
+ const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, isMonitored);
+
+ return (
+
+
+
+
+
+
+
+ {
+ !!queueItem &&
+
+
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+ {
+ showCutoffUnmetIcon &&
+ !!movieFile &&
+ movieFile.qualityCutoffNotMet &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+CalendarEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ movieFile: PropTypes.object,
+ title: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ inCinemas: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarEvent;
diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js
new file mode 100644
index 000000000..7da98888e
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventConnector.js
@@ -0,0 +1,29 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarEvent from './CalendarEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createMovieSelector(),
+ createMovieFileSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (calendarOptions, series, episodeFile, queueItem, uiSettings) => {
+ return {
+ series,
+ episodeFile,
+ queueItem,
+ ...calendarOptions,
+ timeFormat: uiSettings.timeFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarEvent);
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css
new file mode 100644
index 000000000..e16498f25
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.css
@@ -0,0 +1,82 @@
+.eventGroup {
+ overflow-x: hidden;
+ margin: 4px 2px;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 4px solid $borderColor;
+ font-size: 12px;
+}
+
+.info,
+.airingInfo {
+ display: flex;
+}
+
+.seriesTitle {
+ @add-mixin truncate;
+
+ flex: 1 0 1px;
+ margin-right: 10px;
+ color: #3a3f51;
+ font-size: 14px;
+}
+
+.airTime {
+ flex: 1 0 1px;
+}
+
+.episodeInfo {
+ margin-left: 10px;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.expandContainerInline {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 0 20px;
+}
+
+.expandContainer,
+.collapseContainer {
+ display: flex;
+ justify-content: center;
+}
+
+.collapseContainer {
+ margin-bottom: 5px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from 'Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
+}
+
+.onAir {
+ composes: onAir from 'Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from 'Calendar/Events/CalendarEvent.css';
+}
+
+.premiere {
+ composes: premiere from 'Calendar/Events/CalendarEvent.css';
+}
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js
new file mode 100644
index 000000000..186085c52
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.js
@@ -0,0 +1,200 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
+import styles from './CalendarEventGroup.css';
+
+function getEventsInfo(events) {
+ let files = 0;
+ let queued = 0;
+ let monitored = 0;
+ let absoluteEpisodeNumbers = 0;
+
+ events.forEach((event) => {
+ if (event.episodeFileId) {
+ files++;
+ }
+
+ if (event.queued) {
+ queued++;
+ }
+
+ if (event.monitored) {
+ monitored++;
+ }
+
+ if (event.absoluteEpisodeNumber) {
+ absoluteEpisodeNumbers++;
+ }
+ });
+
+ return {
+ allDownloaded: files === events.length,
+ anyQueued: queued > 0,
+ anyMonitored: monitored > 0,
+ allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length
+ };
+}
+
+class CalendarEventGroup extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isExpanded: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onExpandPress = () => {
+ this.setState({ isExpanded: !this.state.isExpanded });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ series,
+ events,
+ isDownloading,
+ showEpisodeInformation,
+ showFinaleIcon,
+ colorImpairedMode,
+ onEventModalOpenToggle
+ } = this.props;
+
+ const { isExpanded } = this.state;
+ const {
+ allDownloaded,
+ anyQueued,
+ anyMonitored
+ } = getEventsInfo(events);
+ const anyDownloading = isDownloading || anyQueued;
+ const firstEpisode = events[0];
+ const lastEpisode = events[events.length -1];
+ const airDateUtc = firstEpisode.airDateUtc;
+ const startTime = moment(airDateUtc);
+ const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
+ const seasonNumber = firstEpisode.seasonNumber;
+ const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored);
+
+ if (isExpanded) {
+ return (
+
+ {
+ events.map((event) => {
+ if (event.isGroup) {
+ return null;
+ }
+
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {series.title}
+
+
+ {
+ anyDownloading &&
+
+ }
+
+ {
+ firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
+
+ }
+
+ {
+ showFinaleIcon &&
+ lastEpisode.episodeNumber !== 1 &&
+ seasonNumber > 0 &&
+ lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount &&
+
+ }
+
+
+ {
+ showEpisodeInformation &&
+
+
+
+ }
+
+ );
+ }
+}
+
+CalendarEventGroup.propTypes = {
+ series: PropTypes.object.isRequired,
+ events: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDownloading: PropTypes.bool.isRequired,
+ showEpisodeInformation: PropTypes.bool.isRequired,
+ showFinaleIcon: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarEventGroup;
diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js
new file mode 100644
index 000000000..e13e5b998
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js
@@ -0,0 +1,37 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarEventGroup from './CalendarEventGroup';
+
+function createIsDownloadingSelector() {
+ return createSelector(
+ (state, { episodeIds }) => episodeIds,
+ (state) => state.queue.details,
+ (episodeIds, details) => {
+ return details.items.some((item) => {
+ return episodeIds.includes(item.episode.id);
+ });
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createMovieSelector(),
+ createIsDownloadingSelector(),
+ createUISettingsSelector(),
+ (calendarOptions, series, isDownloading, uiSettings) => {
+ return {
+ series,
+ isDownloading,
+ ...calendarOptions,
+ timeFormat: uiSettings.timeFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarEventGroup);
diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
new file mode 100644
index 000000000..81d81465c
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import colors from 'Styles/Variables/colors';
+import CircularProgressBar from 'Components/CircularProgressBar';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+
+function CalendarEventQueueDetails(props) {
+ const {
+ title,
+ size,
+ sizeleft,
+ estimatedCompletionTime,
+ status,
+ errorMessage
+ } = props;
+
+ const progress = (100 - sizeleft / size * 100);
+
+ return (
+
+
+
+ }
+ />
+ );
+}
+
+CalendarEventQueueDetails.propTypes = {
+ title: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ estimatedCompletionTime: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ errorMessage: PropTypes.string
+};
+
+export default CalendarEventQueueDetails;
diff --git a/frontend/src/Calendar/Header/CalendarHeader.css b/frontend/src/Calendar/Header/CalendarHeader.css
new file mode 100644
index 000000000..1127bb3c3
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.css
@@ -0,0 +1,53 @@
+.header {
+ display: flex;
+}
+
+.navigationButtons {
+ flex: 1 1 33%;
+ text-align: left;
+}
+
+.todayButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-left: 5px;
+}
+
+.titleDesktop,
+.titleMobile {
+ text-align: center;
+ font-size: 18px;
+}
+
+.titleMobile {
+ margin-bottom: 5px;
+}
+
+.viewButtonsContainer {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 1 33%;
+}
+
+.viewMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ line-height: 31px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 5px;
+ margin-right: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .navigationButtons {
+ flex: 1 0 50%;
+ }
+
+ .viewButtonsContainer {
+ flex: 0 0 100px;
+ }
+}
diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js
new file mode 100644
index 000000000..6736e4b8c
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.js
@@ -0,0 +1,224 @@
+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 ?
+
+
+
+
+
+
+
+ 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..9bc6303f3
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import LegendItem from './LegendItem';
+import LegendIconItem from './LegendIconItem';
+import styles from './Legend.css';
+
+function Legend(props) {
+ const {
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ colorImpairedMode
+ } = props;
+
+ const iconsToShow = [];
+ if (showFinaleIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ if (showSpecialIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ if (showCutoffUnmetIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {iconsToShow[0]}
+
+
+ {
+ iconsToShow.length > 1 &&
+
+ {iconsToShow[1]}
+ {iconsToShow[2]}
+
+ }
+
+ );
+}
+
+Legend.propTypes = {
+ showFinaleIcon: PropTypes.bool.isRequired,
+ showSpecialIcon: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default Legend;
diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js
new file mode 100644
index 000000000..30bbc4adb
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendConnector.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import Legend from './Legend';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ createUISettingsSelector(),
+ (calendarOptions, uiSettings) => {
+ return {
+ ...calendarOptions,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Legend);
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css
new file mode 100644
index 000000000..01db0ba5a
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendIconItem.css
@@ -0,0 +1,10 @@
+.legendIconItem {
+ margin: 3px 0;
+ margin-right: 6px;
+ width: 150px;
+ cursor: default;
+}
+
+.icon {
+ margin-right: 5px;
+}
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js
new file mode 100644
index 000000000..13e106784
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendIconItem.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import styles from './LegendIconItem.css';
+
+function LegendIconItem(props) {
+ const {
+ name,
+ icon,
+ kind,
+ tooltip
+ } = props;
+
+ return (
+
+
+
+ {name}
+
+ );
+}
+
+LegendIconItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ icon: PropTypes.object.isRequired,
+ kind: PropTypes.string.isRequired,
+ tooltip: PropTypes.string.isRequired
+};
+
+export default LegendIconItem;
diff --git a/frontend/src/Calendar/Legend/LegendItem.css b/frontend/src/Calendar/Legend/LegendItem.css
new file mode 100644
index 000000000..d146e9d68
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.css
@@ -0,0 +1,41 @@
+.legendItem {
+ margin: 3px 0;
+ margin-right: 6px;
+ padding-left: 5px;
+ width: 150px;
+ border-left-width: 4px;
+ border-left-style: solid;
+ cursor: default;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from 'Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
+}
+
+.onAir {
+ composes: onAir from 'Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from 'Calendar/Events/CalendarEvent.css';
+}
+
+.premiere {
+ composes: premiere from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unaired {
+ composes: unaired from 'Calendar/Events/CalendarEvent.css';
+}
diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js
new file mode 100644
index 000000000..961f48b86
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import titleCase from 'Utilities/String/titleCase';
+import styles from './LegendItem.css';
+
+function LegendItem(props) {
+ const {
+ name,
+ status,
+ tooltip,
+ colorImpairedMode
+ } = props;
+
+ return (
+
+ {name ? name : titleCase(status)}
+
+ );
+}
+
+LegendItem.propTypes = {
+ name: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ tooltip: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default LegendItem;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js
new file mode 100644
index 000000000..b68c83f30
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
+
+function CalendarOptionsModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+CalendarOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarOptionsModal;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js
new file mode 100644
index 000000000..9ff7b3f82
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js
@@ -0,0 +1,258 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings';
+
+class CalendarOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ } = props;
+
+ this.state = {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ } = this.props;
+
+ if (
+ prevProps.firstDayOfWeek !== firstDayOfWeek ||
+ prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
+ prevProps.timeFormat !== timeFormat ||
+ prevProps.enableColorImpairedMode !== enableColorImpairedMode
+ ) {
+ this.setState({
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onOptionInputChange = ({ name, value }) => {
+ const {
+ dispatchSetCalendarOption
+ } = this.props;
+
+ dispatchSetCalendarOption({ [name]: value });
+ }
+
+ onGlobalInputChange = ({ name, value }) => {
+ const {
+ dispatchSaveUISettings
+ } = this.props;
+
+ const setting = { [name]: value };
+
+ this.setState(setting, () => {
+ dispatchSaveUISettings(setting);
+ });
+ }
+
+ onLinkFocus = (event) => {
+ event.target.select();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ collapseMultipleEpisodes,
+ showEpisodeInformation,
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ onModalClose
+ } = this.props;
+
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ } = this.state;
+
+ return (
+
+
+ Calendar Options
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+CalendarOptionsModalContent.propTypes = {
+ collapseMultipleEpisodes: PropTypes.bool.isRequired,
+ showEpisodeInformation: PropTypes.bool.isRequired,
+ showFinaleIcon: PropTypes.bool.isRequired,
+ showSpecialIcon: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ firstDayOfWeek: PropTypes.number.isRequired,
+ calendarWeekColumnHeader: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ enableColorImpairedMode: PropTypes.bool.isRequired,
+ dispatchSetCalendarOption: PropTypes.func.isRequired,
+ dispatchSaveUISettings: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarOptionsModalContent;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
new file mode 100644
index 000000000..eb979f74e
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setCalendarOption } from 'Store/Actions/calendarActions';
+import CalendarOptionsModalContent from './CalendarOptionsModalContent';
+import { saveUISettings } from 'Store/Actions/settingsActions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ (state) => state.settings.ui.item,
+ (options, uiSettings) => {
+ return {
+ ...options,
+ ...uiSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetCalendarOption: setCalendarOption,
+ dispatchSaveUISettings: saveUISettings
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);
diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js
new file mode 100644
index 000000000..627e1bb61
--- /dev/null
+++ b/frontend/src/Calendar/calendarViews.js
@@ -0,0 +1,4 @@
+export const MONTH = 'month';
+export const AGENDA = 'agenda';
+
+export const all = [MONTH, AGENDA];
diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js
new file mode 100644
index 000000000..871cccd5b
--- /dev/null
+++ b/frontend/src/Calendar/getStatusStyle.js
@@ -0,0 +1,26 @@
+/* eslint max-params: 0 */
+import moment from 'moment';
+
+function getStatusStyle(hasFile, downloading, startTime, isMonitored) {
+ const currentTime = moment();
+
+ if (hasFile) {
+ return 'downloaded';
+ }
+
+ if (downloading) {
+ return 'downloading';
+ }
+
+ if (!isMonitored) {
+ return 'unmonitored';
+ }
+
+ if (startTime.isBefore(currentTime) && !hasFile) {
+ return 'missing';
+ }
+
+ return 'unaired';
+}
+
+export default getStatusStyle;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js
new file mode 100644
index 000000000..8cc487c16
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
+
+function CalendarLinkModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+CalendarLinkModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarLinkModal;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
new file mode 100644
index 000000000..600a4ba61
--- /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.Radarr.urlBase}/feed/calendar/Radarr.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.Radarr.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 (
+
+
+ Radarr 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..fc73bafdb
--- /dev/null
+++ b/frontend/src/Commands/commandNames.js
@@ -0,0 +1,18 @@
+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 INTERACTIVE_IMPORT = 'ManualImport';
+export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch';
+export const MOVE_MOVIE = 'MoveMovie';
+export const REFRESH_MOVIE = 'RefreshMovie';
+export const RENAME_FILES = 'RenameFiles';
+export const RENAME_SERIES = 'RenameSeries';
+export const RESET_API_KEY = 'ResetApiKey';
+export const RSS_SYNC = 'RssSync';
+export const MOVIE_SEARCH = 'MoviesSearch';
diff --git a/frontend/src/Components/Alert.css b/frontend/src/Components/Alert.css
new file mode 100644
index 000000000..312fbb4f2
--- /dev/null
+++ b/frontend/src/Components/Alert.css
@@ -0,0 +1,31 @@
+.alert {
+ display: block;
+ margin: 5px;
+ padding: 15px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+
+.danger {
+ border-color: $alertDangerBorderColor;
+ background-color: $alertDangerBackgroundColor;
+ color: $alertDangerColor;
+}
+
+.info {
+ border-color: $alertInfoBorderColor;
+ background-color: $alertInfoBackgroundColor;
+ color: $alertInfoColor;
+}
+
+.success {
+ border-color: $alertSuccessBorderColor;
+ background-color: $alertSuccessBackgroundColor;
+ color: $alertSuccessColor;
+}
+
+.warning {
+ border-color: $alertWarningBorderColor;
+ background-color: $alertWarningBackgroundColor;
+ color: $alertWarningColor;
+}
diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js
new file mode 100644
index 000000000..dc19a418c
--- /dev/null
+++ b/frontend/src/Components/Alert.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import styles from './Alert.css';
+
+function Alert({ className, kind, children, ...otherProps }) {
+ return (
+
+ {children}
+
+ );
+}
+
+Alert.propTypes = {
+ className: PropTypes.string.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ children: PropTypes.node.isRequired
+};
+
+Alert.defaultProps = {
+ className: styles.alert,
+ kind: kinds.INFO
+};
+
+export default Alert;
diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css
new file mode 100644
index 000000000..b54bbcdf4
--- /dev/null
+++ b/frontend/src/Components/Card.css
@@ -0,0 +1,19 @@
+.card {
+ position: relative;
+ margin: 10px;
+ padding: 10px;
+ border-radius: 3px;
+ background-color: $white;
+ box-shadow: 0 0 10px 1px $cardShadowColor;
+ color: $defaultColor;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ position: relative;
+}
diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js
new file mode 100644
index 000000000..c5a4d164c
--- /dev/null
+++ b/frontend/src/Components/Card.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './Card.css';
+
+class Card extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ overlayClassName,
+ overlayContent,
+ children,
+ onPress
+ } = this.props;
+
+ if (overlayContent) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+Card.propTypes = {
+ className: PropTypes.string.isRequired,
+ overlayClassName: PropTypes.string.isRequired,
+ overlayContent: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+Card.defaultProps = {
+ className: styles.card,
+ overlayClassName: styles.overlay,
+ overlayContent: false
+};
+
+export default Card;
diff --git a/frontend/src/Components/CircularProgressBar.css b/frontend/src/Components/CircularProgressBar.css
new file mode 100644
index 000000000..32b349404
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.css
@@ -0,0 +1,21 @@
+.circularProgressBarContainer {
+ position: relative;
+ display: inline-block;
+ vertical-align: top;
+ text-align: center;
+}
+
+.circularProgressBar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform: rotate(-90deg);
+ transform-origin: center center;
+}
+
+.circularProgressBarText {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ font-weight: bold;
+}
diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js
new file mode 100644
index 000000000..b526eef3b
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import colors from 'Styles/Variables/colors';
+import styles from './CircularProgressBar.css';
+
+class CircularProgressBar extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ progress: 0
+ };
+ }
+
+ componentDidMount() {
+ this._progressStep();
+ }
+
+ componentDidUpdate(prevProps) {
+ const progress = this.props.progress;
+
+ if (prevProps.progress !== progress) {
+ this._cancelProgressStep();
+ this._progressStep();
+ }
+ }
+
+ componentWillUnmount() {
+ this._cancelProgressStep();
+ }
+
+ //
+ // Control
+
+ _progressStep() {
+ this.requestAnimationFrame = window.requestAnimationFrame(() => {
+ this.setState({
+ progress: this.state.progress + 1
+ }, () => {
+ if (this.state.progress < this.props.progress) {
+ this._progressStep();
+ }
+ });
+ });
+ }
+
+ _cancelProgressStep() {
+ if (this.requestAnimationFrame) {
+ window.cancelAnimationFrame(this.requestAnimationFrame);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ containerClassName,
+ size,
+ strokeWidth,
+ strokeColor,
+ showProgressText
+ } = this.props;
+
+ const progress = this.state.progress;
+
+ const center = size / 2;
+ const radius = center - strokeWidth;
+ const circumference = Math.PI * (radius * 2);
+ const sizeInPixels = `${size}px`;
+ const strokeDashoffset = ((100 - progress) / 100) * circumference;
+ const progressText = `${Math.round(progress)}%`;
+
+ return (
+
+
+
+
+
+ {
+ showProgressText &&
+
+ {progressText}
+
+ }
+
+ );
+ }
+}
+
+CircularProgressBar.propTypes = {
+ className: PropTypes.string,
+ containerClassName: PropTypes.string,
+ size: PropTypes.number,
+ progress: PropTypes.number.isRequired,
+ strokeWidth: PropTypes.number,
+ strokeColor: PropTypes.string,
+ showProgressText: PropTypes.bool
+};
+
+CircularProgressBar.defaultProps = {
+ className: styles.circularProgressBar,
+ containerClassName: styles.circularProgressBarContainer,
+ size: 60,
+ strokeWidth: 5,
+ strokeColor: colors.radarrYellow,
+ showProgressText: false
+};
+
+export default CircularProgressBar;
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css b/frontend/src/Components/DescriptionList/DescriptionList.css
new file mode 100644
index 000000000..230347f80
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.css
@@ -0,0 +1,4 @@
+.descriptionList {
+ margin-top: 0;
+ margin-bottom: 0;
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js
new file mode 100644
index 000000000..be2c87c55
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './DescriptionList.css';
+
+class DescriptionList extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+DescriptionList.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node
+};
+
+DescriptionList.defaultProps = {
+ className: styles.descriptionList
+};
+
+export default DescriptionList;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js
new file mode 100644
index 000000000..4ba70bf33
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import DescriptionListItemTitle from './DescriptionListItemTitle';
+import DescriptionListItemDescription from './DescriptionListItemDescription';
+
+class DescriptionListItem extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ titleClassName,
+ descriptionClassName,
+ title,
+ data
+ } = this.props;
+
+ return (
+
+
+ {title}
+
+
+
+ {data}
+
+
+ );
+ }
+}
+
+DescriptionListItem.propTypes = {
+ titleClassName: PropTypes.string,
+ descriptionClassName: PropTypes.string,
+ title: PropTypes.string,
+ data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
+};
+
+export default DescriptionListItem;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
new file mode 100644
index 000000000..b23415a76
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
@@ -0,0 +1,13 @@
+.description {
+ line-height: $lineHeight;
+}
+
+.description {
+ margin-left: 0;
+}
+
+@media (min-width: 768px) {
+ .description {
+ margin-left: 180px;
+ }
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js
new file mode 100644
index 000000000..4ef3c015e
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './DescriptionListItemDescription.css';
+
+function DescriptionListItemDescription(props) {
+ const {
+ className,
+ children
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+DescriptionListItemDescription.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
+};
+
+DescriptionListItemDescription.defaultProps = {
+ className: styles.description
+};
+
+export default DescriptionListItemDescription;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css
new file mode 100644
index 000000000..e496e463d
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css
@@ -0,0 +1,18 @@
+.title {
+ line-height: $lineHeight;
+}
+
+.title {
+ font-weight: bold;
+}
+
+@media (min-width: 768px) {
+ .title {
+ @add-mixin truncate;
+
+ float: left;
+ clear: left;
+ width: 160px;
+ text-align: right;
+ }
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js
new file mode 100644
index 000000000..e1632c1cf
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './DescriptionListItemTitle.css';
+
+function DescriptionListItemTitle(props) {
+ const {
+ className,
+ children
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+DescriptionListItemTitle.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.string
+};
+
+DescriptionListItemTitle.defaultProps = {
+ className: styles.title
+};
+
+export default DescriptionListItemTitle;
diff --git a/frontend/src/Components/DragPreviewLayer.css b/frontend/src/Components/DragPreviewLayer.css
new file mode 100644
index 000000000..46f721fef
--- /dev/null
+++ b/frontend/src/Components/DragPreviewLayer.css
@@ -0,0 +1,9 @@
+.dragLayer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 9999;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js
new file mode 100644
index 000000000..a111df70e
--- /dev/null
+++ b/frontend/src/Components/DragPreviewLayer.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './DragPreviewLayer.css';
+
+function DragPreviewLayer({ children, ...otherProps }) {
+ return (
+
+ {children}
+
+ );
+}
+
+DragPreviewLayer.propTypes = {
+ children: PropTypes.node,
+ className: PropTypes.string
+};
+
+DragPreviewLayer.defaultProps = {
+ className: styles.dragLayer
+};
+
+export default DragPreviewLayer;
diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js
new file mode 100644
index 000000000..50fcf98b5
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundary.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import * as sentry from '@sentry/browser';
+
+class ErrorBoundary extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ error: null,
+ info: null
+ };
+ }
+
+ componentDidCatch(error, info) {
+ this.setState({
+ error,
+ info
+ });
+
+ sentry.captureException(error);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ errorComponent: ErrorComponent,
+ ...otherProps
+ } = this.props;
+
+ const {
+ error,
+ info
+ } = this.state;
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return children;
+ }
+}
+
+ErrorBoundary.propTypes = {
+ children: PropTypes.node.isRequired,
+ errorComponent: PropTypes.func.isRequired
+};
+
+export default ErrorBoundary;
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css
new file mode 100644
index 000000000..b6d1f917e
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundaryError.css
@@ -0,0 +1,38 @@
+.container {
+ text-align: center;
+}
+
+.message {
+ margin: 50px 0;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.imageContainer {
+ display: flex;
+ justify-content: center;
+ flex: 0 0 auto;
+}
+
+.image {
+ height: 350px;
+}
+
+.details {
+ margin: 20px;
+ text-align: left;
+ white-space: pre-wrap;
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .image {
+ height: 250px;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .image {
+ height: 150px;
+ }
+}
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js
new file mode 100644
index 000000000..05cf8165a
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundaryError.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './ErrorBoundaryError.css';
+
+function ErrorBoundaryError(props) {
+ const {
+ className,
+ messageClassName,
+ detailsClassName,
+ message,
+ error,
+ info
+ } = props;
+
+ return (
+
+
+ {message}
+
+
+
+
+
+
+
+ {
+ error &&
+
+ {error.toString()}
+
+ }
+
+
+ {info.componentStack}
+
+
+
+ );
+}
+
+ErrorBoundaryError.propTypes = {
+ className: PropTypes.string.isRequired,
+ messageClassName: PropTypes.string.isRequired,
+ detailsClassName: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ error: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired
+};
+
+ErrorBoundaryError.defaultProps = {
+ className: styles.container,
+ messageClassName: styles.message,
+ detailsClassName: styles.details,
+ message: 'There was an error loading this content'
+};
+
+export default ErrorBoundaryError;
diff --git a/frontend/src/Components/FieldSet.css b/frontend/src/Components/FieldSet.css
new file mode 100644
index 000000000..daf3bdf2e
--- /dev/null
+++ b/frontend/src/Components/FieldSet.css
@@ -0,0 +1,19 @@
+.fieldSet {
+ margin: 0;
+ margin-bottom: 20px;
+ padding: 0;
+ min-width: 0;
+ border: 0;
+}
+
+.legend {
+ display: block;
+ margin-bottom: 21px;
+ padding: 0;
+ width: 100%;
+ border: 0;
+ border-bottom: 1px solid #e5e5e5;
+ color: #3a3f51;
+ font-size: 21px;
+ line-height: inherit;
+}
diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js
new file mode 100644
index 000000000..76e68a934
--- /dev/null
+++ b/frontend/src/Components/FieldSet.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './FieldSet.css';
+
+class FieldSet extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ legend,
+ children
+ } = this.props;
+
+ return (
+
+
+ {legend}
+
+ {children}
+
+ );
+ }
+
+}
+
+FieldSet.propTypes = {
+ legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ children: PropTypes.node
+};
+
+export default FieldSet;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css b/frontend/src/Components/FileBrowser/FileBrowserModal.css
new file mode 100644
index 000000000..30b936800
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css
@@ -0,0 +1,5 @@
+.modal {
+ composes: modal from 'Components/Modal/Modal.css';
+
+ height: 600px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js
new file mode 100644
index 000000000..6b58dbb8c
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
+import styles from './FileBrowserModal.css';
+
+class FileBrowserModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+FileBrowserModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FileBrowserModal;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css
new file mode 100644
index 000000000..9ae11f0bd
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css
@@ -0,0 +1,33 @@
+.modalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex-direction: column;
+}
+
+.mappedDrivesWarning {
+ composes: alert from 'Components/Alert.css';
+
+ margin: 0;
+ margin-bottom: 20px;
+}
+
+.faqLink {
+ color: $alertWarningColor;
+ font-weight: bold;
+}
+
+.pathInput {
+ composes: pathInputWrapper from 'Components/Form/PathInput.css';
+
+ flex: 0 0 auto;
+}
+
+.scroller {
+ margin-top: 20px;
+}
+
+.loading {
+ display: inline-block;
+ margin-right: auto;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
new file mode 100644
index 000000000..cc8bc7529
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
@@ -0,0 +1,253 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { kinds, scrollDirections } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Scroller from 'Components/Scroller/Scroller';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import PathInput from 'Components/Form/PathInput';
+import FileBrowserRow from './FileBrowserRow';
+import styles from './FileBrowserModalContent.css';
+
+const columns = [
+ {
+ name: 'type',
+ label: 'Type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ }
+];
+
+class FileBrowserModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scrollerNode = null;
+
+ this.state = {
+ isFileBrowserModalOpen: false,
+ currentPath: props.value
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ currentPath
+ } = this.props;
+
+ if (
+ currentPath !== this.state.currentPath &&
+ currentPath !== prevState.currentPath
+ ) {
+ this.setState({ currentPath });
+ this._scrollerNode.scrollTop = 0;
+ }
+ }
+
+ //
+ // Control
+
+ setScrollerRef = (ref) => {
+ if (ref) {
+ this._scrollerNode = ReactDOM.findDOMNode(ref);
+ } else {
+ this._scrollerNode = null;
+ }
+ }
+
+ //
+ // Listeners
+
+ onPathInputChange = ({ value }) => {
+ this.setState({ currentPath: value });
+ }
+
+ onRowPress = (path) => {
+ this.props.onFetchPaths(path);
+ }
+
+ onOkPress = () => {
+ this.props.onChange({
+ name: this.props.name,
+ value: this.state.currentPath
+ });
+
+ this.props.onClearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ directories,
+ files,
+ isWindowsService,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const emptyParent = parent === '';
+
+ return (
+
+
+ File Browser
+
+
+
+ {
+ isWindowsService &&
+
+ Mapped network drives are not available when running as a Windows Service, see the FAQ for more information.
+
+ }
+
+
+
+
+ {
+ !!error &&
+ Error loading contents
+ }
+
+ {
+ isPopulated && !error &&
+
+
+ {
+ emptyParent &&
+
+ }
+
+ {
+ !emptyParent && parent &&
+
+ }
+
+ {
+ directories.map((directory) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ files.map((file) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+
+ Cancel
+
+
+
+ Ok
+
+
+
+ );
+ }
+}
+
+FileBrowserModalContent.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ parent: PropTypes.string,
+ currentPath: PropTypes.string.isRequired,
+ directories: PropTypes.arrayOf(PropTypes.object).isRequired,
+ files: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isWindowsService: PropTypes.bool.isRequired,
+ onFetchPaths: PropTypes.func.isRequired,
+ onClearPaths: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FileBrowserModalContent;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
new file mode 100644
index 000000000..fe577b896
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
@@ -0,0 +1,101 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchPaths, clearPaths } from 'Store/Actions/pathActions';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import FileBrowserModalContent from './FileBrowserModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.paths,
+ createSystemStatusSelector(),
+ (paths, systemStatus) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ currentPath,
+ directories,
+ files
+ } = paths;
+
+ const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ currentPath,
+ directories,
+ files,
+ paths: filteredPaths,
+ isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchPaths,
+ clearPaths
+};
+
+class FileBrowserModalContentConnector extends Component {
+
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchPaths({
+ path: this.props.value,
+ allowFoldersWithoutTrailingSlashes: true
+ });
+ }
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ this.props.fetchPaths({
+ path,
+ allowFoldersWithoutTrailingSlashes: true
+ });
+ }
+
+ onClearPaths = () => {
+ // this.props.clearPaths();
+ }
+
+ onModalClose = () => {
+ this.props.clearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+FileBrowserModalContentConnector.propTypes = {
+ value: PropTypes.string,
+ fetchPaths: PropTypes.func.isRequired,
+ clearPaths: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css b/frontend/src/Components/FileBrowser/FileBrowserRow.css
new file mode 100644
index 000000000..a9c34be6a
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css
@@ -0,0 +1,5 @@
+.type {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 32px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js
new file mode 100644
index 000000000..42ac30405
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './FileBrowserRow.css';
+
+function getIconName(type) {
+ switch (type) {
+ case 'computer':
+ return icons.COMPUTER;
+ case 'drive':
+ return icons.DRIVE;
+ case 'file':
+ return icons.FILE;
+ case 'parent':
+ return icons.PARENT;
+ default:
+ return icons.FOLDER;
+ }
+}
+
+class FileBrowserRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.path);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ type,
+ name
+ } = this.props;
+
+ return (
+
+
+
+
+
+ {name}
+
+ );
+ }
+
+}
+
+FileBrowserRow.propTypes = {
+ type: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default FileBrowserRow;
diff --git a/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js
new file mode 100644
index 000000000..eea574dd1
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: true, name: 'true' },
+ { id: false, name: 'false' }
+];
+
+function BoolFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default BoolFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css
new file mode 100644
index 000000000..fd56a4917
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css
@@ -0,0 +1,15 @@
+.container {
+ display: flex;
+}
+
+.numberInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ margin-right: 3px;
+}
+
+.selectInput {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ margin-left: 3px;
+}
diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js
new file mode 100644
index 000000000..f0c2d3626
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js
@@ -0,0 +1,171 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import isString from 'Utilities/String/isString';
+import { IN_LAST, IN_NEXT } from 'Helpers/Props/filterTypes';
+import NumberInput from 'Components/Form/NumberInput';
+import SelectInput from 'Components/Form/SelectInput';
+import TextInput from 'Components/Form/TextInput';
+import { NAME } from './FilterBuilderRowValue';
+import styles from './DateFilterBuilderRowValue.css';
+
+const timeOptions = [
+ { key: 'seconds', value: 'seconds' },
+ { key: 'minutes', value: 'minutes' },
+ { key: 'hours', value: 'hours' },
+ { key: 'days', value: 'days' },
+ { key: 'weeks', value: 'weeks' },
+ { key: 'months', value: 'months' }
+];
+
+function isInFilter(filterType) {
+ return filterType === IN_LAST || filterType === IN_NEXT;
+}
+
+class DateFilterBuilderRowValue extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ filterType,
+ filterValue,
+ onChange
+ } = this.props;
+
+ if (isInFilter(filterType) && isString(filterValue)) {
+ onChange({
+ name: NAME,
+ value: {
+ time: timeOptions[0].key,
+ value: null
+ }
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ filterType,
+ filterValue,
+ onChange
+ } = this.props;
+
+ if (prevProps.filterType === filterType) {
+ return;
+ }
+
+ if (isInFilter(filterType) && isString(filterValue)) {
+ onChange({
+ name: NAME,
+ value: {
+ time: timeOptions[0].key,
+ value: null
+ }
+ });
+
+ return;
+ }
+
+ if (!isInFilter(filterType) && !isString(filterValue)) {
+ onChange({
+ name: NAME,
+ value: ''
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onValueChange = ({ value }) => {
+ const {
+ filterValue,
+ onChange
+ } = this.props;
+
+ let newValue = value;
+
+ if (!isString(value)) {
+ newValue = {
+ time: filterValue.time,
+ value
+ };
+ }
+
+ onChange({
+ name: NAME,
+ value: newValue
+ });
+ }
+
+ onTimeChange = ({ value }) => {
+ const {
+ filterValue,
+ onChange
+ } = this.props;
+
+ onChange({
+ name: NAME,
+ value: {
+ time: value,
+ value: filterValue.value
+ }
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterType,
+ filterValue
+ } = this.props;
+
+ if (
+ (isInFilter(filterType) && isString(filterValue)) ||
+ (!isInFilter(filterType) && !isString(filterValue))
+ ) {
+ return null;
+ }
+
+ if (isInFilter(filterType)) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+DateFilterBuilderRowValue.propTypes = {
+ filterType: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+export default DateFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css
new file mode 100644
index 000000000..6cc8fab67
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css
@@ -0,0 +1,16 @@
+.labelContainer {
+ margin-bottom: 20px;
+}
+
+.label {
+ margin-bottom: 5px;
+ font-weight: bold;
+}
+
+.labelInputContainer {
+ width: 300px;
+}
+
+.rows {
+ margin-bottom: 100px;
+}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
new file mode 100644
index 000000000..ed3bc2409
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
@@ -0,0 +1,226 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import FilterBuilderRow from './FilterBuilderRow';
+import styles from './FilterBuilderModalContent.css';
+
+class FilterBuilderModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const filters = [...props.filters];
+
+ // Push an empty filter if there aren't any filters. FilterBuilderRow
+ // will handle initializing the filter.
+
+ if (!filters.length) {
+ filters.push({});
+ }
+
+ this.state = {
+ label: props.label,
+ filters,
+ labelErrors: []
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ customFilters,
+ isSaving,
+ saveError,
+ dispatchSetFilter,
+ onModalClose
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ if (id) {
+ dispatchSetFilter({ selectedFilterKey: id });
+ } else {
+ const last = customFilters[customFilters.length -1];
+ dispatchSetFilter({ selectedFilterKey: last.id });
+ }
+
+ onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onLabelChange = ({ value }) => {
+ this.setState({ label: value });
+ }
+
+ onFilterChange = (index, filter) => {
+ const filters = [...this.state.filters];
+ filters.splice(index, 1, filter);
+
+ this.setState({
+ filters
+ });
+ }
+
+ onAddFilterPress = () => {
+ const filters = [...this.state.filters];
+ filters.push({});
+
+ this.setState({
+ filters
+ });
+ }
+
+ onRemoveFilterPress = (index) => {
+ const filters = [...this.state.filters];
+ filters.splice(index, 1);
+
+ this.setState({
+ filters
+ });
+ }
+
+ onSaveFilterPress = () => {
+ const {
+ id,
+ customFilterType,
+ onSaveCustomFilterPress
+ } = this.props;
+
+ const {
+ label,
+ filters
+ } = this.state;
+
+ if (!label) {
+ this.setState({
+ labelErrors: [
+ {
+ message: 'Label is required'
+ }
+ ]
+ });
+
+ return;
+ }
+
+ onSaveCustomFilterPress({
+ id,
+ type: customFilterType,
+ label,
+ filters
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ sectionItems,
+ filterBuilderProps,
+ isSaving,
+ saveError,
+ onModalClose
+ } = this.props;
+
+ const {
+ label,
+ filters,
+ labelErrors
+ } = this.state;
+
+ return (
+
+
+ Custom Filter
+
+
+
+
+
+ Filters
+
+
+ {
+ filters.map((filter, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+FilterBuilderModalContent.propTypes = {
+ id: PropTypes.number,
+ label: PropTypes.string.isRequired,
+ customFilterType: PropTypes.string.isRequired,
+ sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ dispatchDeleteCustomFilter: PropTypes.func.isRequired,
+ onSaveCustomFilterPress: PropTypes.func.isRequired,
+ dispatchSetFilter: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FilterBuilderModalContent;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js
new file mode 100644
index 000000000..c94db9925
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js
@@ -0,0 +1,42 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { saveCustomFilter, deleteCustomFilter } from 'Store/Actions/customFilterActions';
+import FilterBuilderModalContent from './FilterBuilderModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { customFilters }) => customFilters,
+ (state, { id }) => id,
+ (state) => state.customFilters.isSaving,
+ (state) => state.customFilters.saveError,
+ (customFilters, id, isSaving, saveError) => {
+ if (id) {
+ const customFilter = customFilters.find((c) => c.id === id);
+
+ return {
+ id: customFilter.id,
+ label: customFilter.label,
+ filters: customFilter.filters,
+ customFilters,
+ isSaving,
+ saveError
+ };
+ }
+
+ return {
+ label: '',
+ filters: [],
+ customFilters,
+ isSaving,
+ saveError
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onSaveCustomFilterPress: saveCustomFilter,
+ dispatchDeleteCustomFilter: deleteCustomFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent);
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css
new file mode 100644
index 000000000..c5471b253
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css
@@ -0,0 +1,32 @@
+.filterRow {
+ display: flex;
+ margin-bottom: 5px;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.inputContainer {
+ flex: 0 1 200px;
+ margin-right: 10px;
+}
+
+.valueInputContainer {
+ flex: 0 1 300px;
+ margin-right: 10px;
+}
+
+.actionsContainer {
+ display: flex;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .filterRow {
+ display: block;
+ }
+
+ .inputContainer {
+ margin-bottom: 10px;
+ }
+}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
new file mode 100644
index 000000000..53f28a90e
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
@@ -0,0 +1,282 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
+import SelectInput from 'Components/Form/SelectInput';
+import IconButton from 'Components/Link/IconButton';
+import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
+import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
+import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
+import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
+import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
+import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
+import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
+import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
+import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
+import styles from './FilterBuilderRow.css';
+
+function getselectedFilterBuilderProp(filterBuilderProps, name) {
+ return filterBuilderProps.find((a) => {
+ return a.name === name;
+ });
+}
+
+function getFilterTypeOptions(filterBuilderProps, filterKey) {
+ const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey);
+
+ if (!selectedFilterBuilderProp) {
+ return [];
+ }
+
+ return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type];
+}
+
+function getDefaultFilterType(selectedFilterBuilderProp) {
+ return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key;
+}
+
+function getDefaultFilterValue(selectedFilterBuilderProp) {
+ if (selectedFilterBuilderProp.type === filterBuilderTypes.DATE) {
+ return '';
+ }
+
+ return [];
+}
+
+function getRowValueConnector(selectedFilterBuilderProp) {
+ if (!selectedFilterBuilderProp) {
+ return FilterBuilderRowValueConnector;
+ }
+
+ const valueType = selectedFilterBuilderProp.valueType;
+
+ switch (valueType) {
+ case filterBuilderValueTypes.BOOL:
+ return BoolFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.DATE:
+ return DateFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.INDEXER:
+ return IndexerFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.PROTOCOL:
+ return ProtocolFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.QUALITY:
+ return QualityFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.QUALITY_PROFILE:
+ return QualityProfileFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.SERIES_STATUS:
+ return SeriesStatusFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.TAG:
+ return TagFilterBuilderRowValueConnector;
+
+ default:
+ return FilterBuilderRowValueConnector;
+ }
+}
+
+class FilterBuilderRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ filterKey,
+ filterBuilderProps
+ } = props;
+
+ if (filterKey) {
+ const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey);
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+ }
+ }
+
+ componentDidMount() {
+ const {
+ index,
+ filterKey,
+ filterBuilderProps,
+ onFilterChange
+ } = this.props;
+
+ if (filterKey) {
+ const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey);
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+
+ return;
+ }
+
+ const selectedFilterBuilderProp = filterBuilderProps[0];
+
+ const filter = {
+ key: selectedFilterBuilderProp.name,
+ value: getDefaultFilterValue(selectedFilterBuilderProp),
+ type: getDefaultFilterType(selectedFilterBuilderProp)
+ };
+
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+ onFilterChange(index, filter);
+ }
+
+ //
+ // Listeners
+
+ onFilterKeyChange = ({ value: key }) => {
+ const {
+ index,
+ filterBuilderProps,
+ onFilterChange
+ } = this.props;
+
+ const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key);
+ const type = getDefaultFilterType(selectedFilterBuilderProp);
+
+ const filter = {
+ key,
+ value: getDefaultFilterValue(selectedFilterBuilderProp),
+ type
+ };
+
+ this.selectedFilterBuilderProp = selectedFilterBuilderProp;
+ onFilterChange(index, filter);
+ }
+
+ onFilterChange = ({ name, value }) => {
+ const {
+ index,
+ filterKey,
+ filterValue,
+ filterType,
+ onFilterChange
+ } = this.props;
+
+ const filter = {
+ key: filterKey,
+ value: filterValue,
+ type: filterType
+ };
+
+ filter[name] = value;
+
+ onFilterChange(index, filter);
+ }
+
+ onAddPress = () => {
+ const {
+ index,
+ onAddPress
+ } = this.props;
+
+ onAddPress(index);
+ }
+
+ onRemovePress = () => {
+ const {
+ index,
+ onRemovePress
+ } = this.props;
+
+ onRemovePress(index);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterKey,
+ filterType,
+ filterValue,
+ filterCount,
+ filterBuilderProps,
+ sectionItems
+ } = this.props;
+
+ const selectedFilterBuilderProp = this.selectedFilterBuilderProp;
+
+ const keyOptions = filterBuilderProps.map((availablePropFilter) => {
+ return {
+ key: availablePropFilter.name,
+ value: availablePropFilter.label
+ };
+ });
+
+ const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
+
+ return (
+
+
+ {
+ filterKey &&
+
+ }
+
+
+
+ {
+ filterType &&
+
+ }
+
+
+
+ {
+ filterValue != null && !!selectedFilterBuilderProp &&
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+FilterBuilderRow.propTypes = {
+ index: PropTypes.number.isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]),
+ filterType: PropTypes.string,
+ filterCount: PropTypes.number.isRequired,
+ filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onFilterChange: PropTypes.func.isRequired,
+ onAddPress: PropTypes.func.isRequired,
+ onRemovePress: PropTypes.func.isRequired
+};
+
+export default FilterBuilderRow;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
new file mode 100644
index 000000000..70c496620
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
@@ -0,0 +1,159 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import convertToBytes from 'Utilities/Number/convertToBytes';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props';
+import TagInput, { tagShape } from 'Components/Form/TagInput';
+import FilterBuilderRowValueTag from './FilterBuilderRowValueTag';
+
+export const NAME = 'value';
+
+function getTagDisplayValue(value, selectedFilterBuilderProp) {
+ if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
+ return formatBytes(value);
+ }
+
+ return value;
+}
+
+function getValue(input, selectedFilterBuilderProp) {
+ if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
+ const match = input.match(/^(\d+)([kmgt](i?b)?)$/i);
+
+ if (match && match.length > 1) {
+ const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i);
+
+ switch (unit.toLowerCase()) {
+ case 'k':
+ return convertToBytes(value, 1, true);
+ case 'm':
+ return convertToBytes(value, 2, true);
+ case 'g':
+ return convertToBytes(value, 3, true);
+ case 't':
+ return convertToBytes(value, 4, true);
+ case 'kb':
+ return convertToBytes(value, 1, true);
+ case 'mb':
+ return convertToBytes(value, 2, true);
+ case 'gb':
+ return convertToBytes(value, 3, true);
+ case 'tb':
+ return convertToBytes(value, 4, true);
+ case 'kib':
+ return convertToBytes(value, 1, true);
+ case 'mib':
+ return convertToBytes(value, 2, true);
+ case 'gib':
+ return convertToBytes(value, 3, true);
+ case 'tib':
+ return convertToBytes(value, 4, true);
+ default:
+ return parseInt(value);
+ }
+ }
+ }
+
+ if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) {
+ return parseInt(input);
+ }
+
+ return input;
+}
+
+class FilterBuilderRowValue extends Component {
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ filterValue,
+ selectedFilterBuilderProp,
+ onChange
+ } = this.props;
+
+ let value = tag.id;
+
+ if (value == null) {
+ value = getValue(tag.name, selectedFilterBuilderProp);
+ }
+
+ onChange({
+ name: NAME,
+ value: [...filterValue, value]
+ });
+ }
+
+ onTagDelete = ({ index }) => {
+ const {
+ filterValue,
+ onChange
+ } = this.props;
+
+ const value = filterValue.filter((v, i) => i !== index);
+
+ onChange({
+ name: NAME,
+ value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterValue,
+ selectedFilterBuilderProp,
+ tagList
+ } = this.props;
+
+ const hasItems = !!tagList.length;
+
+ const tags = filterValue.map((id) => {
+ if (hasItems) {
+ const tag = tagList.find((t) => t.id === id);
+
+ return {
+ id,
+ name: tag && tag.name
+ };
+ }
+
+ return {
+ id,
+ name: getTagDisplayValue(id, selectedFilterBuilderProp)
+ };
+ });
+
+ return (
+
+ );
+ }
+}
+
+FilterBuilderRowValue.propTypes = {
+ filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])).isRequired,
+ selectedFilterBuilderProp: PropTypes.object.isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+FilterBuilderRowValue.defaultProps = {
+ filterValue: []
+};
+
+export default FilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..ac74240e4
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
@@ -0,0 +1,55 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import sortByName from 'Utilities/Array/sortByName';
+import { filterBuilderTypes } from 'Helpers/Props';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createTagListSelector() {
+ return createSelector(
+ (state, { sectionItems }) => sectionItems,
+ (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp,
+ (sectionItems, selectedFilterBuilderProp) => {
+ if (
+ selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ||
+ selectedFilterBuilderProp.type === filterBuilderTypes.STRING
+ ) {
+ return [];
+ }
+
+ let items = [];
+
+ if (selectedFilterBuilderProp.optionsSelector) {
+ items = selectedFilterBuilderProp.optionsSelector(sectionItems);
+ } else {
+ items = sectionItems.reduce((acc, item) => {
+ const name = item[selectedFilterBuilderProp.name];
+
+ if (name) {
+ acc.push({
+ id: name,
+ name
+ });
+ }
+
+ return acc;
+ }, []).sort(sortByName);
+ }
+
+ return _.uniqBy(items, 'id');
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagListSelector(),
+ (tagList) => {
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css
new file mode 100644
index 000000000..1c4c5acf1
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css
@@ -0,0 +1,19 @@
+.tag {
+ &.isLastTag {
+ .or {
+ display: none;
+ }
+ }
+}
+
+.label {
+ composes: label from 'Components/Label.css';
+
+ border-style: none;
+ font-size: 13px;
+}
+
+.or {
+ margin: 0 3px;
+ color: $themeDarkColor;
+}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
new file mode 100644
index 000000000..573e05759
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import TagInputTag from 'Components/Form/TagInputTag';
+import styles from './FilterBuilderRowValueTag.css';
+
+function FilterBuilderRowValueTag(props) {
+ return (
+
+
+
+ {
+ !props.isLastTag &&
+
+ or
+
+ }
+
+ );
+}
+
+FilterBuilderRowValueTag.propTypes = {
+ isLastTag: PropTypes.bool.isRequired
+};
+
+export default FilterBuilderRowValueTag;
diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..968b26d2c
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexers } from 'Store/Actions/settingsActions';
+import { tagShape } from 'Components/Form/TagInput';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (qualityProfiles) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = qualityProfiles;
+
+ const tagList = items.map((item) => {
+ return {
+ id: item.id,
+ name: item.name
+ };
+ });
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchIndexers: fetchIndexers
+};
+
+class IndexerFilterBuilderRowValueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchIndexers();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+IndexerFilterBuilderRowValueConnector.propTypes = {
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ dispatchFetchIndexers: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector);
diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js
new file mode 100644
index 000000000..ae63ae0eb
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: 'torrent', name: 'Torrent' },
+ { id: 'usenet', name: 'Usenet' }
+];
+
+function ProtocolFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default ProtocolFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..0290bcdcb
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js
@@ -0,0 +1,75 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import getQualities from 'Utilities/Quality/getQualities';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import { tagShape } from 'Components/Form/TagInput';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = qualityProfiles;
+
+ const tagList = getQualities(schema.items);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityProfileSchema: fetchQualityProfileSchema
+};
+
+class QualityFilterBuilderRowValueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+QualityFilterBuilderRowValueConnector.propTypes = {
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector);
diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..4a8b82283
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const tagList = qualityProfiles.items.map((qualityProfile) => {
+ const {
+ id,
+ name
+ } = qualityProfile;
+
+ return {
+ id,
+ name
+ };
+ });
+
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
new file mode 100644
index 000000000..50841a013
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: 'continuing', name: 'Continuing' },
+ { id: 'ended', name: 'Ended' }
+];
+
+function SeriesStatusFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default SeriesStatusFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..60e04c446
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagsSelector(),
+ (tagList) => {
+ return {
+ tagList: tagList.map((tag) => {
+ const {
+ id,
+ label: name
+ } = tag;
+
+ return {
+ id,
+ name
+ };
+ })
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css
new file mode 100644
index 000000000..7acb69dc7
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css
@@ -0,0 +1,17 @@
+.customFilter {
+ display: flex;
+ margin-bottom: 5px;
+ padding: 5px;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.label {
+ flex: 0 1 300px;
+}
+
+.actions {
+ flex: 0 0 60px;
+}
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
new file mode 100644
index 000000000..c9c326d78
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import styles from './CustomFilter.css';
+
+class CustomFilter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDeleting: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDeleting,
+ deleteError
+ } = this.props;
+
+ if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) {
+ this.setState({ isDeleting: false });
+ }
+ }
+
+ componentWillUnmount() {
+ const {
+ id,
+ selectedFilterKey,
+ dispatchSetFilter
+ } = this.props;
+
+ // Assume that delete and then unmounting means the delete was successful.
+ // Moving this check to a ancestor would be more accurate, but would have
+ // more boilerplate.
+ if (this.state.isDeleting && id === selectedFilterKey) {
+ dispatchSetFilter({ selectedFilterKey: 'all' });
+ }
+ }
+
+ //
+ // Listeners
+
+ onEditPress = () => {
+ const {
+ id,
+ onEditPress
+ } = this.props;
+
+ onEditPress(id);
+ }
+
+ onRemovePress = () => {
+ const {
+ id,
+ dispatchDeleteCustomFilter
+ } = this.props;
+
+ this.setState({ isDeleting: true }, () => {
+ dispatchDeleteCustomFilter({ id });
+ });
+
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ label
+ } = this.props;
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+CustomFilter.propTypes = {
+ id: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ dispatchSetFilter: PropTypes.func.isRequired,
+ onEditPress: PropTypes.func.isRequired,
+ dispatchDeleteCustomFilter: PropTypes.func.isRequired
+};
+
+export default CustomFilter;
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css
new file mode 100644
index 000000000..c391764dc
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css
@@ -0,0 +1,3 @@
+.addButtonContainer {
+ margin-top: 15px;
+}
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
new file mode 100644
index 000000000..1a7168fca
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import CustomFilter from './CustomFilter';
+import styles from './CustomFiltersModalContent.css';
+
+function CustomFiltersModalContent(props) {
+ const {
+ selectedFilterKey,
+ customFilters,
+ isDeleting,
+ deleteError,
+ dispatchDeleteCustomFilter,
+ dispatchSetFilter,
+ onAddCustomFilter,
+ onEditCustomFilter,
+ onModalClose
+ } = props;
+
+ return (
+
+
+ Custom Filters
+
+
+
+ {
+ customFilters.map((customFilter, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ Add Custom Filter
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+CustomFiltersModalContent.propTypes = {
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ dispatchDeleteCustomFilter: PropTypes.func.isRequired,
+ dispatchSetFilter: PropTypes.func.isRequired,
+ onAddCustomFilter: PropTypes.func.isRequired,
+ onEditCustomFilter: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CustomFiltersModalContent;
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js
new file mode 100644
index 000000000..32425d766
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteCustomFilter } from 'Store/Actions/customFilterActions';
+import CustomFiltersModalContent from './CustomFiltersModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.customFilters.isDeleting,
+ (state) => state.customFilters.deleteError,
+ (isDeleting, deleteError) => {
+ return {
+ isDeleting,
+ deleteError
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchDeleteCustomFilter: deleteCustomFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent);
diff --git a/frontend/src/Components/Filter/FilterModal.js b/frontend/src/Components/Filter/FilterModal.js
new file mode 100644
index 000000000..750d1ed48
--- /dev/null
+++ b/frontend/src/Components/Filter/FilterModal.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector';
+import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector';
+
+class FilterModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ filterBuilder: !props.customFilters.length,
+ id: null
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddCustomFilter = () => {
+ this.setState({
+ filterBuilder: true
+ });
+ }
+
+ onEditCustomFilter = (id) => {
+ this.setState({
+ filterBuilder: true,
+ id
+ });
+ }
+
+ onModalClose = () => {
+ this.setState({
+ filterBuilder: false,
+ id: null
+ }, () => {
+ this.props.onModalClose();
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ ...otherProps
+ } = this.props;
+
+ const {
+ filterBuilder,
+ id
+ } = this.state;
+
+ return (
+
+ {
+ filterBuilder ?
+ :
+
+ }
+
+ );
+ }
+}
+
+FilterModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FilterModal;
diff --git a/frontend/src/Components/Form/AutoCompleteInput.css b/frontend/src/Components/Form/AutoCompleteInput.css
new file mode 100644
index 000000000..417a71437
--- /dev/null
+++ b/frontend/src/Components/Form/AutoCompleteInput.css
@@ -0,0 +1,58 @@
+.input {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.inputWrapper {
+ display: flex;
+}
+
+.inputContainer {
+ position: relative;
+ flex-grow: 1;
+}
+
+.container {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.inputContainerOpen {
+ .container {
+ position: absolute;
+ z-index: 1;
+ overflow-y: auto;
+ max-height: 200px;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ }
+}
+
+.list {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.listItem {
+ padding: 0 16px;
+}
+
+.match {
+ font-weight: bold;
+}
+
+.highlighted {
+ background-color: $menuItemHoverBackgroundColor;
+}
diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js
new file mode 100644
index 000000000..740726b36
--- /dev/null
+++ b/frontend/src/Components/Form/AutoCompleteInput.js
@@ -0,0 +1,162 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import classNames from 'classnames';
+import jdu from 'jdu';
+import styles from './AutoCompleteInput.css';
+
+class AutoCompleteInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ suggestions: []
+ };
+ }
+
+ //
+ // Control
+
+ getSuggestionValue(item) {
+ return item;
+ }
+
+ renderSuggestion(item) {
+ return item;
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (event, { newValue }) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: newValue
+ });
+ }
+
+ onInputKeyDown = (event) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const { suggestions } = this.state;
+
+ if (
+ event.key === 'Tab' &&
+ suggestions.length &&
+ suggestions[0] !== this.props.value
+ ) {
+ event.preventDefault();
+
+ if (value) {
+ onChange({
+ name,
+ value: suggestions[0]
+ });
+ }
+ }
+ }
+
+ onInputBlur = () => {
+ this.setState({ suggestions: [] });
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const { values } = this.props;
+ const lowerCaseValue = jdu.replace(value).toLowerCase();
+
+ const filteredValues = values.filter((v) => {
+ return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
+ });
+
+ this.setState({ suggestions: filteredValues });
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.setState({ suggestions: [] });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ inputClassName,
+ name,
+ value,
+ placeholder,
+ hasError,
+ hasWarning
+ } = this.props;
+
+ const { suggestions } = this.state;
+
+ const inputProps = {
+ className: classNames(
+ inputClassName,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning,
+ ),
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onInputChange,
+ onKeyDown: this.onInputKeyDown,
+ onBlur: this.onInputBlur
+ };
+
+ const theme = {
+ container: styles.inputContainer,
+ containerOpen: styles.inputContainerOpen,
+ suggestionsContainer: styles.container,
+ suggestionsList: styles.list,
+ suggestion: styles.listItem,
+ suggestionHighlighted: styles.highlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+AutoCompleteInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ values: PropTypes.arrayOf(PropTypes.string).isRequired,
+ placeholder: PropTypes.string,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ onChange: PropTypes.func.isRequired
+};
+
+AutoCompleteInput.defaultProps = {
+ className: styles.inputWrapper,
+ inputClassName: styles.input,
+ value: ''
+};
+
+export default AutoCompleteInput;
diff --git a/frontend/src/Components/Form/CaptchaInput.css b/frontend/src/Components/Form/CaptchaInput.css
new file mode 100644
index 000000000..e7cd1dc4e
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.css
@@ -0,0 +1,23 @@
+.captchaInputWrapper {
+ display: flex;
+}
+
+.input {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.hasButton {
+ composes: hasButton from 'Components/Form/Input.css';
+}
+
+.recaptchaWrapper {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js
new file mode 100644
index 000000000..e1a5df458
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReCAPTCHA from 'react-google-recaptcha';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputButton from './FormInputButton';
+import TextInput from './TextInput';
+import styles from './CaptchaInput.css';
+
+function CaptchaInput(props) {
+ const {
+ className,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ refreshing,
+ siteKey,
+ secretToken,
+ onChange,
+ onRefreshPress,
+ onCaptchaChange
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ !!siteKey && !!secretToken &&
+
+
+
+ }
+
+ );
+}
+
+CaptchaInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ refreshing: PropTypes.bool.isRequired,
+ siteKey: PropTypes.string,
+ secretToken: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onCaptchaChange: PropTypes.func.isRequired
+};
+
+CaptchaInput.defaultProps = {
+ className: styles.input,
+ value: ''
+};
+
+export default CaptchaInput;
diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js
new file mode 100644
index 000000000..17b875c88
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInputConnector.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { refreshCaptcha, getCaptchaCookie, resetCaptcha } from 'Store/Actions/captchaActions';
+import CaptchaInput from './CaptchaInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.captcha,
+ (captcha) => {
+ return captcha;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ refreshCaptcha,
+ getCaptchaCookie,
+ resetCaptcha
+};
+
+class CaptchaInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ name,
+ token,
+ onChange
+ } = this.props;
+
+ if (token && token !== prevProps.token) {
+ onChange({ name, value: token });
+ }
+ }
+
+ componentWillUnmount = () => {
+ this.props.resetCaptcha();
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ const {
+ provider,
+ providerData
+ } = this.props;
+
+ this.props.refreshCaptcha({ provider, providerData });
+ }
+
+ onCaptchaChange = (captchaResponse) => {
+ // If the captcha has expired `captchaResponse` will be null.
+ // In the event it's null don't try to get the captchaCookie.
+ // TODO: Should we clear the cookie? or reset the captcha?
+
+ if (!captchaResponse) {
+ return;
+ }
+
+ const {
+ provider,
+ providerData
+ } = this.props;
+
+ this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CaptchaInputConnector.propTypes = {
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ name: PropTypes.string.isRequired,
+ token: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ refreshCaptcha: PropTypes.func.isRequired,
+ getCaptchaCookie: PropTypes.func.isRequired,
+ resetCaptcha: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector);
diff --git a/frontend/src/Components/Form/CheckInput.css b/frontend/src/Components/Form/CheckInput.css
new file mode 100644
index 000000000..5c35e5d2f
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.css
@@ -0,0 +1,105 @@
+.container {
+ position: relative;
+ display: flex;
+ flex: 1 1 65%;
+ user-select: none;
+}
+
+.label {
+ display: flex;
+ margin-bottom: 0;
+ min-height: 21px;
+ font-weight: normal;
+ cursor: pointer;
+}
+
+.checkbox {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ pointer-events: none;
+
+ &:global(.isDisabled) {
+ cursor: not-allowed;
+ }
+}
+
+.input {
+ flex: 1 0 auto;
+ margin-top: 7px;
+ margin-right: 5px;
+ width: 20px;
+ height: 20px;
+ border: 1px solid #ccc;
+ border-radius: 2px;
+ background-color: $white;
+ color: $white;
+ text-align: center;
+ line-height: 20px;
+}
+
+.checkbox:focus + .input {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+}
+
+.dangerIsChecked {
+ border-color: $dangerColor;
+ background-color: $dangerColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.primaryIsChecked {
+ border-color: $primaryColor;
+ background-color: $primaryColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.successIsChecked {
+ border-color: $successColor;
+ background-color: $successColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.warningIsChecked {
+ border-color: $warningColor;
+ background-color: $warningColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.isNotChecked {
+ &.isDisabled {
+ border-color: $disabledCheckInputColor;
+ background-color: $disabledCheckInputColor;
+ opacity: 0.7;
+ }
+}
+
+.isIndeterminate {
+ border-color: $gray;
+ background-color: $gray;
+}
+
+.helpText {
+ composes: helpText from 'Components/Form/FormInputHelpText.css';
+
+ margin-top: 8px;
+ margin-left: 5px;
+}
+
+.isDisabled {
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js
new file mode 100644
index 000000000..134290111
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.js
@@ -0,0 +1,191 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputHelpText from './FormInputHelpText';
+import styles from './CheckInput.css';
+
+class CheckInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._checkbox = null;
+ }
+
+ componentDidMount() {
+ this.setIndeterminate();
+ }
+
+ componentDidUpdate() {
+ this.setIndeterminate();
+ }
+
+ //
+ // Control
+
+ setIndeterminate() {
+ if (!this._checkbox) {
+ return;
+ }
+
+ const {
+ value,
+ uncheckedValue,
+ checkedValue
+ } = this.props;
+
+ this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
+ }
+
+ toggleChecked = (checked, shiftKey) => {
+ const {
+ name,
+ value,
+ checkedValue,
+ uncheckedValue
+ } = this.props;
+
+ const newValue = checked ? checkedValue : uncheckedValue;
+
+ if (value !== newValue) {
+ this.props.onChange({
+ name,
+ value: newValue,
+ shiftKey
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ setRef = (ref) => {
+ this._checkbox = ref;
+ }
+
+ onClick = (event) => {
+ if (this.props.isDisabled) {
+ return;
+ }
+
+ const shiftKey = event.nativeEvent.shiftKey;
+ const checked = !this._checkbox.checked;
+
+ event.preventDefault();
+ this.toggleChecked(checked, shiftKey);
+ }
+
+ onChange = (event) => {
+ const checked = event.target.checked;
+ const shiftKey = event.nativeEvent.shiftKey;
+
+ this.toggleChecked(checked, shiftKey);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ containerClassName,
+ name,
+ value,
+ checkedValue,
+ uncheckedValue,
+ helpText,
+ helpTextWarning,
+ isDisabled,
+ kind
+ } = this.props;
+
+ const isChecked = value === checkedValue;
+ const isUnchecked = value === uncheckedValue;
+ const isIndeterminate = !isChecked && !isUnchecked;
+ const isCheckClass = `${kind}IsChecked`;
+
+ return (
+
+
+
+
+
+ {
+ isChecked &&
+
+ }
+
+ {
+ isIndeterminate &&
+
+ }
+
+
+ {
+ helpText &&
+
+ }
+
+ {
+ !helpText && helpTextWarning &&
+
+ }
+
+
+ );
+ }
+}
+
+CheckInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ containerClassName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ checkedValue: PropTypes.bool,
+ uncheckedValue: PropTypes.bool,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ helpText: PropTypes.string,
+ helpTextWarning: PropTypes.string,
+ isDisabled: PropTypes.bool,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+CheckInput.defaultProps = {
+ className: styles.input,
+ containerClassName: styles.container,
+ checkedValue: true,
+ uncheckedValue: false,
+ kind: kinds.PRIMARY
+};
+
+export default CheckInput;
diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css
new file mode 100644
index 000000000..0c518e98e
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInput.css
@@ -0,0 +1,8 @@
+.deviceInputWrapper {
+ display: flex;
+}
+
+.inputContainer {
+ composes: inputContainer from './TagInput.css';
+ composes: hasButton from 'Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js
new file mode 100644
index 000000000..a38648e1a
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInput.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputButton from './FormInputButton';
+import TagInput, { tagShape } from './TagInput';
+import styles from './DeviceInput.css';
+
+class DeviceInput extends Component {
+
+ onTagAdd = (device) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ // New tags won't have an ID, only a name.
+ const deviceId = device.id || device.name;
+
+ onChange({
+ name,
+ value: [...value, deviceId]
+ });
+ }
+
+ onTagDelete = ({ index }) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ items,
+ selectedDevices,
+ hasError,
+ hasWarning,
+ isFetching,
+ onRefreshPress
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DeviceInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
+ items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired
+};
+
+DeviceInput.defaultProps = {
+ className: styles.deviceInputWrapper,
+ inputClassName: styles.input
+};
+
+export default DeviceInput;
diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js
new file mode 100644
index 000000000..d53372b35
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInputConnector.js
@@ -0,0 +1,99 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDevices, clearDevices } from 'Store/Actions/deviceActions';
+import DeviceInput from './DeviceInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ (state) => state.devices,
+ (value, devices) => {
+
+ return {
+ ...devices,
+ selectedDevices: value.map((valueDevice) => {
+ // Disable equality ESLint rule so we don't need to worry about
+ // a type mismatch between the value items and the device ID.
+ // eslint-disable-next-line eqeqeq
+ const device = devices.items.find((d) => d.id == valueDevice);
+
+ if (device) {
+ return {
+ id: device.id,
+ name: `${device.name} (${device.id})`
+ };
+ }
+
+ return {
+ id: valueDevice,
+ name: `Unknown (${valueDevice})`
+ };
+ })
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchDevices: fetchDevices,
+ dispatchClearDevices: clearDevices
+};
+
+class DeviceInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ this._populate();
+ }
+
+ componentWillUnmount = () => {
+ // this.props.dispatchClearDevices();
+ }
+
+ //
+ // Control
+
+ _populate() {
+ const {
+ provider,
+ providerData,
+ dispatchFetchDevices
+ } = this.props;
+
+ dispatchFetchDevices({ provider, providerData });
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this._populate();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DeviceInputConnector.propTypes = {
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ name: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ dispatchFetchDevices: PropTypes.func.isRequired,
+ dispatchClearDevices: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css
new file mode 100644
index 000000000..568e35f40
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css
@@ -0,0 +1,79 @@
+.tether {
+ z-index: 2000;
+}
+
+.enhancedSelect {
+ composes: input from 'Components/Form/Input.css';
+ composes: link from 'Components/Link/Link.css';
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ color: $black;
+ cursor: default;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.isDisabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.dropdownArrowContainer {
+ margin-left: 12px;
+}
+
+.dropdownArrowContainerDisabled {
+ composes: dropdownArrowContainer;
+
+ color: $disabledInputColor;
+}
+
+.optionsContainer {
+ width: auto;
+}
+
+.options {
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
+
+.optionsModal {
+ display: flex;
+ justify-content: center;
+ max-width: 90%;
+ width: 350px !important;
+ height: auto !important;
+}
+
+.optionsModalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ padding: 10px 0;
+}
+
+.optionsModalScroller {
+ composes: scroller from 'Components/Scroller/Scroller.css';
+
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
new file mode 100644
index 000000000..a127feaed
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -0,0 +1,411 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { icons, scrollDirections } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import Measure from 'Components/Measure';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import Scroller from 'Components/Scroller/Scroller';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import EnhancedSelectInputOption from './EnhancedSelectInputOption';
+import styles from './EnhancedSelectInput.css';
+
+const tetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ],
+ attachment: 'top left',
+ targetAttachment: 'bottom left'
+};
+
+function isArrowKey(keyCode) {
+ return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
+}
+
+function getSelectedOption(selectedIndex, values) {
+ return values[selectedIndex];
+}
+
+function findIndex(startingIndex, direction, values) {
+ let indexToTest = startingIndex + direction;
+
+ while (indexToTest !== startingIndex) {
+ if (indexToTest < 0) {
+ indexToTest = values.length - 1;
+ } else if (indexToTest >= values.length) {
+ indexToTest = 0;
+ }
+
+ if (getSelectedOption(indexToTest, values).isDisabled) {
+ indexToTest = indexToTest + direction;
+ } else {
+ return indexToTest;
+ }
+ }
+}
+
+function previousIndex(selectedIndex, values) {
+ return findIndex(selectedIndex, -1, values);
+}
+
+function nextIndex(selectedIndex, values) {
+ return findIndex(selectedIndex, 1, values);
+}
+
+function getSelectedIndex(props) {
+ const {
+ value,
+ values
+ } = props;
+
+ return values.findIndex((v) => {
+ return v.key === value;
+ });
+}
+
+function getKey(selectedIndex, values) {
+ return values[selectedIndex].key;
+}
+
+class EnhancedSelectInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false,
+ selectedIndex: getSelectedIndex(props),
+ width: 0,
+ isMobile: isMobileUtil()
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.value !== this.props.value) {
+ this.setState({
+ selectedIndex: getSelectedIndex(this.props)
+ });
+ }
+ }
+
+ //
+ // Control
+
+ _setButtonRef = (ref) => {
+ this._buttonRef = ref;
+ }
+
+ _setOptionsRef = (ref) => {
+ this._optionsRef = ref;
+ }
+
+ _addListener() {
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const button = ReactDOM.findDOMNode(this._buttonRef);
+ const options = ReactDOM.findDOMNode(this._optionsRef);
+
+ if (!button || this.state.isMobile) {
+ return;
+ }
+
+ if (
+ !button.contains(event.target) &&
+ options &&
+ !options.contains(event.target) &&
+ this.state.isOpen
+ ) {
+ this.setState({ isOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onBlur = () => {
+ this.setState({
+ selectedIndex: getSelectedIndex(this.props)
+ });
+ }
+
+ onKeyDown = (event) => {
+ const {
+ values
+ } = this.props;
+
+ const {
+ isOpen,
+ selectedIndex
+ } = this.state;
+
+ const keyCode = event.keyCode;
+ const newState = {};
+
+ if (!isOpen) {
+ if (isArrowKey(keyCode)) {
+ event.preventDefault();
+ newState.isOpen = true;
+ }
+
+ if (
+ selectedIndex == null ||
+ getSelectedOption(selectedIndex, values).isDisabled
+ ) {
+ if (keyCode === keyCodes.UP_ARROW) {
+ newState.selectedIndex = previousIndex(0, values);
+ } else if (keyCode === keyCodes.DOWN_ARROW) {
+ newState.selectedIndex = nextIndex(values.length - 1, values);
+ }
+ }
+
+ this.setState(newState);
+ return;
+ }
+
+ if (keyCode === keyCodes.UP_ARROW) {
+ event.preventDefault();
+ newState.selectedIndex = previousIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.DOWN_ARROW) {
+ event.preventDefault();
+ newState.selectedIndex = nextIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.ENTER) {
+ event.preventDefault();
+ newState.isOpen = false;
+ this.onSelect(getKey(selectedIndex, values));
+ }
+
+ if (keyCode === keyCodes.TAB) {
+ newState.isOpen = false;
+ this.onSelect(getKey(selectedIndex, values));
+ }
+
+ if (keyCode === keyCodes.ESCAPE) {
+ event.preventDefault();
+ event.stopPropagation();
+ newState.isOpen = false;
+ newState.selectedIndex = getSelectedIndex(this.props);
+ }
+
+ if (!_.isEmpty(newState)) {
+ this.setState(newState);
+ }
+ }
+
+ onPress = () => {
+ if (this.state.isOpen) {
+ this._removeListener();
+ } else {
+ this._addListener();
+ }
+
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onSelect = (value) => {
+ this.setState({ isOpen: false });
+
+ this.props.onChange({
+ name: this.props.name,
+ value
+ });
+ }
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ }
+
+ onOptionsModalClose = () => {
+ this.setState({ isOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ disabledClassName,
+ values,
+ isDisabled,
+ hasError,
+ hasWarning,
+ selectedValueOptions,
+ selectedValueComponent: SelectedValueComponent,
+ optionComponent: OptionComponent
+ } = this.props;
+
+ const {
+ selectedIndex,
+ width,
+ isOpen,
+ isMobile
+ } = this.state;
+
+ const selectedOption = getSelectedOption(selectedIndex, values);
+
+ return (
+
+
+
+
+
+ {selectedOption ? selectedOption.value : null}
+
+
+
+
+
+
+
+
+ {
+ isOpen && !isMobile &&
+
+
+ {
+ values.map((v, index) => {
+ return (
+
+ {v.value}
+
+ );
+ })
+ }
+
+
+ }
+
+
+ {
+ isMobile &&
+
+
+
+ {
+ values.map((v, index) => {
+ return (
+
+ {v.value}
+
+ );
+ })
+ }
+
+
+
+ }
+
+ );
+ }
+}
+
+EnhancedSelectInput.propTypes = {
+ className: PropTypes.string,
+ disabledClassName: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDisabled: PropTypes.bool,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ selectedValueOptions: PropTypes.object.isRequired,
+ selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ optionComponent: PropTypes.func,
+ onChange: PropTypes.func.isRequired
+};
+
+EnhancedSelectInput.defaultProps = {
+ className: styles.enhancedSelect,
+ disabledClassName: styles.isDisabled,
+ isDisabled: false,
+ selectedValueOptions: {},
+ selectedValueComponent: EnhancedSelectInputSelectedValue,
+ optionComponent: EnhancedSelectInputOption
+};
+
+export default EnhancedSelectInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css
new file mode 100644
index 000000000..2b96de47f
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css
@@ -0,0 +1,41 @@
+.option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 5px 10px;
+ width: 100%;
+ cursor: default;
+
+ &:hover {
+ background-color: #f9f9f9;
+ }
+}
+
+.isSelected {
+ background-color: #e2e2e2;
+
+ &.isMobile {
+ background-color: inherit;
+
+ .iconContainer {
+ color: $primaryColor;
+ }
+ }
+}
+
+.isDisabled {
+ background-color: #aaa;
+}
+
+.isHidden {
+ display: none;
+}
+
+.isMobile {
+ height: 50px;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-child {
+ border: none;
+ }
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
new file mode 100644
index 000000000..e1b410c28
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './EnhancedSelectInputOption.css';
+
+class EnhancedSelectInputOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ id,
+ onSelect
+ } = this.props;
+
+ onSelect(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ isSelected,
+ isDisabled,
+ isHidden,
+ isMobile,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ {
+ isMobile &&
+
+
+
+ }
+
+ );
+ }
+}
+
+EnhancedSelectInputOption.propTypes = {
+ className: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isHidden: PropTypes.bool.isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ onSelect: PropTypes.func.isRequired
+};
+
+EnhancedSelectInputOption.defaultProps = {
+ className: styles.option,
+ isDisabled: false,
+ isHidden: false
+};
+
+export default EnhancedSelectInputOption;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
new file mode 100644
index 000000000..6b8b73af9
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
@@ -0,0 +1,7 @@
+.selectedValue {
+ flex: 1 1 auto;
+}
+
+.isDisabled {
+ color: $disabledInputColor;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
new file mode 100644
index 000000000..c40ee93c1
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './EnhancedSelectInputSelectedValue.css';
+
+function EnhancedSelectInputSelectedValue(props) {
+ const {
+ className,
+ children,
+ isDisabled
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+EnhancedSelectInputSelectedValue.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ isDisabled: PropTypes.bool.isRequired
+};
+
+EnhancedSelectInputSelectedValue.defaultProps = {
+ className: styles.selectedValue,
+ isDisabled: false
+};
+
+export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Form.css b/frontend/src/Components/Form/Form.css
new file mode 100644
index 000000000..52e79aec4
--- /dev/null
+++ b/frontend/src/Components/Form/Form.css
@@ -0,0 +1,3 @@
+.validationFailures {
+ margin-bottom: 20px;
+}
diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js
new file mode 100644
index 000000000..c2c67eddf
--- /dev/null
+++ b/frontend/src/Components/Form/Form.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import styles from './Form.css';
+
+function Form({ children, validationErrors, validationWarnings, ...otherProps }) {
+ return (
+
+ {
+ validationErrors.length || validationWarnings.length ?
+
+ {
+ validationErrors.map((error, index) => {
+ return (
+
+ {error.errorMessage}
+
+ );
+ })
+ }
+
+ {
+ validationWarnings.map((warning, index) => {
+ return (
+
+ {warning.errorMessage}
+
+ );
+ })
+ }
+
:
+ null
+ }
+
+ {children}
+
+ );
+}
+
+Form.propTypes = {
+ children: PropTypes.node.isRequired,
+ validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired,
+ validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+Form.defaultProps = {
+ validationErrors: [],
+ validationWarnings: []
+};
+
+export default Form;
diff --git a/frontend/src/Components/Form/FormGroup.css b/frontend/src/Components/Form/FormGroup.css
new file mode 100644
index 000000000..ddce8863b
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.css
@@ -0,0 +1,28 @@
+.group {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+/* Sizes */
+
+.extraSmall {
+ max-width: $formGroupExtraSmallWidth;
+}
+
+.small {
+ max-width: $formGroupSmallWidth;
+}
+
+.medium {
+ max-width: $formGroupMediumWidth;
+}
+
+.large {
+ max-width: $formGroupLargeWidth;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .group {
+ display: block;
+ }
+}
diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js
new file mode 100644
index 000000000..d2e04c350
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { map } from 'Helpers/elementChildren';
+import { sizes } from 'Helpers/Props';
+import styles from './FormGroup.css';
+
+function FormGroup(props) {
+ const {
+ className,
+ children,
+ size,
+ advancedSettings,
+ isAdvanced,
+ ...otherProps
+ } = props;
+
+ if (!advancedSettings && isAdvanced) {
+ return null;
+ }
+
+ const childProps = isAdvanced ? { isAdvanced } : {};
+
+ return (
+
+ {
+ map(children, (child) => {
+ return React.cloneElement(child, childProps);
+ })
+ }
+
+ );
+}
+
+FormGroup.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ size: PropTypes.oneOf(sizes.all).isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormGroup.defaultProps = {
+ className: styles.group,
+ size: sizes.SMALL,
+ advancedSettings: false,
+ isAdvanced: false
+};
+
+export default FormGroup;
diff --git a/frontend/src/Components/Form/FormInputButton.css b/frontend/src/Components/Form/FormInputButton.css
new file mode 100644
index 000000000..27a1923be
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.css
@@ -0,0 +1,12 @@
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ border-left: none;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.middleButton {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js
new file mode 100644
index 000000000..4b6491663
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import { kinds } from 'Helpers/Props';
+import styles from './FormInputButton.css';
+
+function FormInputButton(props) {
+ const {
+ className,
+ canSpin,
+ isLastButton,
+ ...otherProps
+ } = props;
+
+ if (canSpin) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+FormInputButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ isLastButton: PropTypes.bool.isRequired,
+ canSpin: PropTypes.bool.isRequired
+};
+
+FormInputButton.defaultProps = {
+ className: styles.button,
+ isLastButton: true,
+ canSpin: false
+};
+
+export default FormInputButton;
diff --git a/frontend/src/Components/Form/FormInputGroup.css b/frontend/src/Components/Form/FormInputGroup.css
new file mode 100644
index 000000000..acdeb772f
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.css
@@ -0,0 +1,49 @@
+.inputGroupContainer {
+ flex: 1 1 auto;
+}
+
+.inputGroup {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+}
+
+.inputContainer {
+ position: relative;
+ flex: 1 1 auto;
+}
+
+.inputUnit {
+ position: absolute;
+ top: 0;
+ right: 20px;
+ margin-top: 7px;
+ width: 75px;
+ color: #c6c6c6;
+ text-align: right;
+ pointer-events: none;
+ user-select: none;
+}
+
+.inputUnitNumber {
+ composes: inputUnit;
+
+ right: 40px;
+}
+
+.pendingChangesContainer {
+ display: flex;
+ justify-content: flex-end;
+ width: 30px;
+}
+
+.pendingChangesIcon {
+ color: $warningColor;
+ font-size: 20px;
+ line-height: 35px;
+}
+
+.helpLink {
+ margin-top: 5px;
+ line-height: 20px;
+}
diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js
new file mode 100644
index 000000000..96af58af2
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -0,0 +1,249 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import AutoCompleteInput from './AutoCompleteInput';
+import CaptchaInputConnector from './CaptchaInputConnector';
+import CheckInput from './CheckInput';
+import DeviceInputConnector from './DeviceInputConnector';
+import NumberInput from './NumberInput';
+import OAuthInputConnector from './OAuthInputConnector';
+import PasswordInput from './PasswordInput';
+import PathInputConnector from './PathInputConnector';
+import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
+import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
+import MovieMonitoredSelectInput from './MovieMonitoredSelectInput';
+import SelectInput from './SelectInput';
+import TagInputConnector from './TagInputConnector';
+import TextTagInputConnector from './TextTagInputConnector';
+import TextInput from './TextInput';
+import FormInputHelpText from './FormInputHelpText';
+import styles from './FormInputGroup.css';
+
+function getComponent(type) {
+ switch (type) {
+ case inputTypes.AUTO_COMPLETE:
+ return AutoCompleteInput;
+
+ case inputTypes.CAPTCHA:
+ return CaptchaInputConnector;
+
+ case inputTypes.CHECK:
+ return CheckInput;
+
+ case inputTypes.DEVICE:
+ return DeviceInputConnector;
+
+ case inputTypes.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.MOVIE_MONITORED_SELECT:
+ return MovieMonitoredSelectInput;
+
+ case inputTypes.ROOT_FOLDER_SELECT:
+ return RootFolderSelectInputConnector;
+
+ case inputTypes.SELECT:
+ return SelectInput;
+
+ case inputTypes.TAG:
+ return TagInputConnector;
+
+ case inputTypes.TEXT_TAG:
+ return TextTagInputConnector;
+
+ default:
+ return TextInput;
+ }
+}
+
+function FormInputGroup(props) {
+ const {
+ className,
+ containerClassName,
+ inputClassName,
+ type,
+ unit,
+ buttons,
+ helpText,
+ helpTexts,
+ helpTextWarning,
+ helpLink,
+ pending,
+ errors,
+ warnings,
+ ...otherProps
+ } = props;
+
+ const InputComponent = getComponent(type);
+ const checkInput = type === inputTypes.CHECK;
+ const hasError = !!errors.length;
+ const hasWarning = !hasError && !!warnings.length;
+ const buttonsArray = React.Children.toArray(buttons);
+ const lastButtonIndex = buttonsArray.length - 1;
+ const hasButton = !!buttonsArray.length;
+
+ return (
+
+
+
+
+
+ {
+ unit &&
+
+ {unit}
+
+ }
+
+
+ {
+ buttonsArray.map((button, index) => {
+ return React.cloneElement(
+ button,
+ {
+ isLastButton: index === lastButtonIndex
+ }
+ );
+ })
+ }
+
+ {/*
+ {
+ pending &&
+
+ }
+
*/}
+
+
+ {
+ !checkInput && helpText &&
+
+ }
+
+ {
+ !checkInput && helpTexts &&
+
+ {
+ helpTexts.map((text, index) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !checkInput && helpTextWarning &&
+
+ }
+
+ {
+ helpLink &&
+
+ More Info
+
+ }
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+FormInputGroup.propTypes = {
+ className: PropTypes.string.isRequired,
+ containerClassName: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ unit: PropTypes.string,
+ buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
+ helpText: PropTypes.string,
+ helpTexts: PropTypes.arrayOf(PropTypes.string),
+ helpTextWarning: PropTypes.string,
+ helpLink: PropTypes.string,
+ pending: PropTypes.bool,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object)
+};
+
+FormInputGroup.defaultProps = {
+ className: styles.inputGroup,
+ containerClassName: styles.inputGroupContainer,
+ type: inputTypes.TEXT,
+ buttons: [],
+ helpTexts: [],
+ errors: [],
+ warnings: []
+};
+
+export default FormInputGroup;
diff --git a/frontend/src/Components/Form/FormInputHelpText.css b/frontend/src/Components/Form/FormInputHelpText.css
new file mode 100644
index 000000000..c760d957c
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.css
@@ -0,0 +1,39 @@
+.helpText {
+ margin-top: 5px;
+ color: $helpTextColor;
+ line-height: 20px;
+}
+
+.isError {
+ color: $dangerColor;
+
+ .link {
+ color: $dangerColor;
+
+ &:hover {
+ color: #e01313;
+ }
+ }
+}
+
+.isWarning {
+ color: $warningColor;
+
+ .link {
+ color: $warningColor;
+
+ &:hover {
+ color: #e36c00;
+ }
+ }
+}
+
+.isCheckInput {
+ padding-left: 30px;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ margin-left: 5px;
+}
diff --git a/frontend/src/Components/Form/FormInputHelpText.js b/frontend/src/Components/Form/FormInputHelpText.js
new file mode 100644
index 000000000..d9195568b
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './FormInputHelpText.css';
+
+function FormInputHelpText(props) {
+ const {
+ className,
+ text,
+ link,
+ linkTooltip,
+ isError,
+ isWarning,
+ isCheckInput
+ } = props;
+
+ return (
+
+ {text}
+
+ {
+ !!link &&
+
+
+
+ }
+
+ );
+}
+
+FormInputHelpText.propTypes = {
+ className: PropTypes.string.isRequired,
+ text: PropTypes.string.isRequired,
+ link: PropTypes.string,
+ linkTooltip: PropTypes.string,
+ isError: PropTypes.bool,
+ isWarning: PropTypes.bool,
+ isCheckInput: PropTypes.bool
+};
+
+FormInputHelpText.defaultProps = {
+ className: styles.helpText,
+ isError: false,
+ isWarning: false,
+ isCheckInput: false
+};
+
+export default FormInputHelpText;
diff --git a/frontend/src/Components/Form/FormLabel.css b/frontend/src/Components/Form/FormLabel.css
new file mode 100644
index 000000000..236f4aab0
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.css
@@ -0,0 +1,29 @@
+.label {
+ display: flex;
+ justify-content: flex-end;
+ margin-right: $formLabelRightMarginWidth;
+ font-weight: bold;
+ line-height: 35px;
+}
+
+.hasError {
+ color: $dangerColor;
+}
+
+.isAdvanced {
+ color: $advancedFormLabelColor;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .label {
+ justify-content: flex-start;
+ }
+}
+
+.small {
+ flex: 0 0 $formLabelSmallWidth;
+}
+
+.large {
+ flex: 0 0 $formLabelLargeWidth;
+}
diff --git a/frontend/src/Components/Form/FormLabel.js b/frontend/src/Components/Form/FormLabel.js
new file mode 100644
index 000000000..da7a443e3
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { sizes } from 'Helpers/Props';
+import styles from './FormLabel.css';
+
+function FormLabel({
+ children,
+ className,
+ errorClassName,
+ size,
+ name,
+ hasError,
+ isAdvanced,
+ ...otherProps
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+FormLabel.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+ errorClassName: PropTypes.string,
+ size: PropTypes.oneOf(sizes.all),
+ name: PropTypes.string,
+ hasError: PropTypes.bool,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormLabel.defaultProps = {
+ className: styles.label,
+ errorClassName: styles.hasError,
+ isAdvanced: false,
+ size: sizes.LARGE
+};
+
+export default FormLabel;
diff --git a/frontend/src/Components/Form/Input.css b/frontend/src/Components/Form/Input.css
new file mode 100644
index 000000000..e9ca23d8f
--- /dev/null
+++ b/frontend/src/Components/Form/Input.css
@@ -0,0 +1,30 @@
+.input {
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+
+ &:focus {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.hasError {
+ border-color: $inputErrorBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputErrorBoxShadowColor;
+}
+
+.hasWarning {
+ border-color: $inputWarningBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputWarningBoxShadowColor;
+}
+
+.hasButton {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
diff --git a/frontend/src/Components/Form/MovieMonitoredSelectInput.js b/frontend/src/Components/Form/MovieMonitoredSelectInput.js
new file mode 100644
index 000000000..2e0139780
--- /dev/null
+++ b/frontend/src/Components/Form/MovieMonitoredSelectInput.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectInput from './SelectInput';
+
+const monitorTypesOptions = [
+ { key: 'true', value: 'True' },
+ { key: 'false', value: 'False' }
+];
+
+function MovieMonitoredSelectInput(props) {
+ const values = [...monitorTypesOptions];
+
+ 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 (
+
+ );
+}
+
+MovieMonitoredSelectInput.propTypes = {
+ includeNoChange: PropTypes.bool.isRequired,
+ includeMixed: PropTypes.bool.isRequired
+};
+
+MovieMonitoredSelectInput.defaultProps = {
+ includeNoChange: false,
+ includeMixed: false
+};
+
+export default MovieMonitoredSelectInput;
diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js
new file mode 100644
index 000000000..c4ecc7e86
--- /dev/null
+++ b/frontend/src/Components/Form/NumberInput.js
@@ -0,0 +1,126 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextInput from './TextInput';
+
+function parseValue(props, value) {
+ const {
+ isFloat,
+ min,
+ max
+ } = props;
+
+ if (value == null || value === '') {
+ return min;
+ }
+
+ let newValue = isFloat ? parseFloat(value) : parseInt(value);
+
+ if (min != null && newValue != null && newValue < min) {
+ newValue = min;
+ } else if (max != null && newValue != null && newValue > max) {
+ newValue = max;
+ }
+
+ return newValue;
+}
+
+class NumberInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ value: props.value == null ? '' : props.value.toString(),
+ isFocused: false
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { value } = this.props;
+
+ if (value !== prevProps.value && !this.state.isFocused) {
+ this.setState({
+ value: value == null ? '' : value.toString()
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ this.setState({ value });
+
+ this.props.onChange({
+ name,
+ value: parseValue(this.props, value)
+ });
+
+ }
+
+ onFocus = () => {
+ this.setState({ isFocused: true });
+ }
+
+ onBlur = () => {
+ const {
+ name,
+ onChange
+ } = this.props;
+
+ const { value } = this.state;
+ const parsedValue = parseValue(this.props, value);
+ const stringValue = parsedValue == null ? '' : parsedValue.toString();
+
+ if (stringValue === value) {
+ this.setState({ isFocused: false });
+ } else {
+ this.setState({
+ value: stringValue,
+ isFocused: false
+ });
+ }
+
+ onChange({
+ name,
+ value: parsedValue
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const value = this.state.value;
+
+ return (
+
+ );
+ }
+}
+
+NumberInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.number,
+ min: PropTypes.number,
+ max: PropTypes.number,
+ isFloat: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+NumberInput.defaultProps = {
+ value: null,
+ isFloat: false
+};
+
+export default NumberInput;
diff --git a/frontend/src/Components/Form/OAuthInput.js b/frontend/src/Components/Form/OAuthInput.js
new file mode 100644
index 000000000..00825b6ba
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInput.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+
+function OAuthInput(props) {
+ const {
+ label,
+ authorizing,
+ error,
+ onPress
+ } = props;
+
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+OAuthInput.propTypes = {
+ label: PropTypes.string.isRequired,
+ authorizing: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ onPress: PropTypes.func.isRequired
+};
+
+OAuthInput.defaultProps = {
+ label: 'Start OAuth'
+};
+
+export default OAuthInput;
diff --git a/frontend/src/Components/Form/OAuthInputConnector.js b/frontend/src/Components/Form/OAuthInputConnector.js
new file mode 100644
index 000000000..7568aae7a
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInputConnector.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { startOAuth, resetOAuth } from 'Store/Actions/oAuthActions';
+import OAuthInput from './OAuthInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.oAuth,
+ (oAuth) => {
+ return oAuth;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ startOAuth,
+ resetOAuth
+};
+
+class OAuthInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ result,
+ onChange
+ } = this.props;
+
+ if (!result || result === prevProps.result) {
+ return;
+ }
+
+ Object.keys(result).forEach((key) => {
+ onChange({ name: key, value: result[key] });
+ });
+ }
+
+ componentWillUnmount = () => {
+ this.props.resetOAuth();
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ provider,
+ providerData,
+ section
+ } = this.props;
+
+ this.props.startOAuth({
+ name,
+ provider,
+ providerData,
+ section
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OAuthInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ result: PropTypes.object,
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ section: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ startOAuth: PropTypes.func.isRequired,
+ resetOAuth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);
diff --git a/frontend/src/Components/Form/PasswordInput.css b/frontend/src/Components/Form/PasswordInput.css
new file mode 100644
index 000000000..fca96bea9
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.css
@@ -0,0 +1,5 @@
+.input {
+ composes: input from 'Components/Form/TextInput.css';
+
+ font-family: $passwordFamily;
+}
diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js
new file mode 100644
index 000000000..adb1e7c5a
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import TextInput from './TextInput';
+import styles from './PasswordInput.css';
+
+function PasswordInput(props) {
+ return (
+
+ );
+}
+
+PasswordInput.propTypes = {
+ className: PropTypes.string.isRequired
+};
+
+PasswordInput.defaultProps = {
+ className: styles.input
+};
+
+export default PasswordInput;
diff --git a/frontend/src/Components/Form/PathInput.css b/frontend/src/Components/Form/PathInput.css
new file mode 100644
index 000000000..ce9fd8ebe
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.css
@@ -0,0 +1,68 @@
+.path {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.hasFileBrowser {
+ composes: hasButton from 'Components/Form/Input.css';
+}
+
+.pathInputWrapper {
+ display: flex;
+}
+
+.pathInputContainer {
+ position: relative;
+ flex-grow: 1;
+}
+
+.pathContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.pathInputContainerOpen {
+ .pathContainer {
+ position: absolute;
+ z-index: 1;
+ overflow-y: auto;
+ max-height: 200px;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ }
+}
+
+.pathList {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.pathListItem {
+ padding: 0 16px;
+}
+
+.pathMatch {
+ font-weight: bold;
+}
+
+.pathHighlighted {
+ background-color: $menuItemHoverBackgroundColor;
+}
+
+.fileBrowserButton {
+ composes: button from './FormInputButton.css';
+
+ height: 35px;
+}
diff --git a/frontend/src/Components/Form/PathInput.js b/frontend/src/Components/Form/PathInput.js
new file mode 100644
index 000000000..b62ba0555
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.js
@@ -0,0 +1,206 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import FormInputButton from './FormInputButton';
+import styles from './PathInput.css';
+
+class PathInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isFileBrowserModalOpen: false
+ };
+ }
+
+ //
+ // Control
+
+ getSuggestionValue({ path }) {
+ return path;
+ }
+
+ renderSuggestion({ path }, { query }) {
+ const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/');
+
+ if (lastSeparatorIndex === -1) {
+ return (
+ {path}
+ );
+ }
+
+ return (
+
+
+ {path.substr(0, lastSeparatorIndex)}
+
+ {path.substr(lastSeparatorIndex)}
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (event, { newValue }) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: newValue
+ });
+ }
+
+ onInputKeyDown = (event) => {
+ if (event.key === 'Tab') {
+ event.preventDefault();
+ const path = this.props.paths[0];
+
+ if (path) {
+ this.props.onChange({
+ name: this.props.name,
+ value: path.path
+ });
+
+ if (path.type !== 'file') {
+ this.props.onFetchPaths(path.path);
+ }
+ }
+ }
+ }
+
+ onInputBlur = () => {
+ this.props.onClearPaths();
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ this.props.onFetchPaths(value);
+ }
+
+ onSuggestionsClearRequested = () => {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+ }
+
+ onSuggestionSelected = (event, { suggestionValue }) => {
+ this.props.onFetchPaths(suggestionValue);
+ }
+
+ onFileBrowserOpenPress = () => {
+ this.setState({ isFileBrowserModalOpen: true });
+ }
+
+ onFileBrowserModalClose = () => {
+ this.setState({ isFileBrowserModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ inputClassName,
+ name,
+ value,
+ placeholder,
+ paths,
+ hasError,
+ hasWarning,
+ hasFileBrowser,
+ onChange
+ } = this.props;
+
+ const inputProps = {
+ className: classNames(
+ inputClassName,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning,
+ hasFileBrowser && styles.hasFileBrowser
+ ),
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onInputChange,
+ onKeyDown: this.onInputKeyDown,
+ onBlur: this.onInputBlur
+ };
+
+ const theme = {
+ container: styles.pathInputContainer,
+ containerOpen: styles.pathInputContainerOpen,
+ suggestionsContainer: styles.pathContainer,
+ suggestionsList: styles.pathList,
+ suggestion: styles.pathListItem,
+ suggestionHighlighted: styles.pathHighlighted
+ };
+
+ return (
+
+
+
+ {
+ hasFileBrowser &&
+
+
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+PathInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ placeholder: PropTypes.string,
+ paths: PropTypes.array.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ hasFileBrowser: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onFetchPaths: PropTypes.func.isRequired,
+ onClearPaths: PropTypes.func.isRequired
+};
+
+PathInput.defaultProps = {
+ className: styles.pathInputWrapper,
+ inputClassName: styles.path,
+ value: '',
+ hasFileBrowser: true
+};
+
+export default PathInput;
diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js
new file mode 100644
index 000000000..4916daec8
--- /dev/null
+++ b/frontend/src/Components/Form/PathInputConnector.js
@@ -0,0 +1,67 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchPaths, clearPaths } from 'Store/Actions/pathActions';
+import PathInput from './PathInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.paths,
+ (paths) => {
+ const {
+ currentPath,
+ directories,
+ files
+ } = paths;
+
+ const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return {
+ paths: filteredPaths
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchPaths,
+ clearPaths
+};
+
+class PathInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ this.props.fetchPaths({ path });
+ }
+
+ onClearPaths = () => {
+ this.props.clearPaths();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+PathInputConnector.propTypes = {
+ fetchPaths: PropTypes.func.isRequired,
+ clearPaths: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
new file mode 100644
index 000000000..98922dae4
--- /dev/null
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -0,0 +1,120 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function getType(type) {
+ switch (type) {
+ case 'captcha':
+ return inputTypes.CAPTCHA;
+ case 'checkbox':
+ return inputTypes.CHECK;
+ case 'device':
+ return inputTypes.DEVICE;
+ case 'password':
+ return inputTypes.PASSWORD;
+ case 'number':
+ return inputTypes.NUMBER;
+ case 'path':
+ return inputTypes.PATH;
+ case 'select':
+ return inputTypes.SELECT;
+ case 'tag':
+ return inputTypes.TEXT_TAG;
+ case 'textbox':
+ return inputTypes.TEXT;
+ case 'oauth':
+ return inputTypes.OAUTH;
+ default:
+ return inputTypes.TEXT;
+ }
+}
+
+function getSelectValues(selectOptions) {
+ if (!selectOptions) {
+ return;
+ }
+
+ return _.reduce(selectOptions, (result, option) => {
+ result.push({
+ key: option.value,
+ value: option.name
+ });
+
+ return result;
+ }, []);
+}
+
+function ProviderFieldFormGroup(props) {
+ const {
+ advancedSettings,
+ name,
+ label,
+ helpText,
+ helpLink,
+ value,
+ type,
+ advanced,
+ pending,
+ errors,
+ warnings,
+ selectOptions,
+ onChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {label}
+
+
+
+ );
+}
+
+const selectOptionsShape = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.number.isRequired
+};
+
+ProviderFieldFormGroup.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ helpText: PropTypes.string,
+ helpLink: PropTypes.string,
+ value: PropTypes.any,
+ type: PropTypes.string.isRequired,
+ advanced: PropTypes.bool.isRequired,
+ pending: PropTypes.bool.isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object).isRequired,
+ warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
+ onChange: PropTypes.func.isRequired
+};
+
+ProviderFieldFormGroup.defaultProps = {
+ advancedSettings: false
+};
+
+export default ProviderFieldFormGroup;
diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
new file mode 100644
index 000000000..16e0e46f2
--- /dev/null
+++ b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
@@ -0,0 +1,98 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import sortByName from 'Utilities/Array/sortByName';
+import SelectInput from './SelectInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (state, { includeNoChange }) => includeNoChange,
+ (state, { includeMixed }) => includeMixed,
+ (qualityProfiles, includeNoChange, includeMixed) => {
+ const values = _.map(qualityProfiles.items.sort(sortByName), (qualityProfile) => {
+ return {
+ key: qualityProfile.id,
+ value: qualityProfile.name
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return {
+ values
+ };
+ }
+ );
+}
+
+class QualityProfileSelectInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ values
+ } = this.props;
+
+ if (!value || !_.some(values, (option) => parseInt(option.key) === value)) {
+ const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key)));
+
+ if (firstValue) {
+ this.onChange({ name, value: firstValue.key });
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ this.props.onChange({ name, value: parseInt(value) });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfileSelectInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+QualityProfileSelectInputConnector.defaultProps = {
+ includeNoChange: false
+};
+
+export default connect(createMapStateToProps)(QualityProfileSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInput.js b/frontend/src/Components/Form/RootFolderSelectInput.js
new file mode 100644
index 000000000..08d88e5f1
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInput.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import EnhancedSelectInput from './EnhancedSelectInput';
+import RootFolderSelectInputOption from './RootFolderSelectInputOption';
+import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
+
+class RootFolderSelectInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNewRootFolderModalOpen: false,
+ newRootFolderPath: ''
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ name,
+ isSaving,
+ saveError,
+ onChange
+ } = this.props;
+
+ const newRootFolderPath = this.state.newRootFolderPath;
+
+ if (
+ prevProps.isSaving &&
+ !isSaving &&
+ !saveError &&
+ newRootFolderPath
+ ) {
+ onChange({ name, value: newRootFolderPath });
+ this.setState({ newRootFolderPath: '' });
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ if (value === 'addNew') {
+ this.setState({ isAddNewRootFolderModalOpen: true });
+ } else {
+ this.props.onChange({ name, value });
+ }
+ }
+
+ onNewRootFolderSelect = ({ value }) => {
+ this.setState({ newRootFolderPath: value }, () => {
+ this.props.onNewRootFolderSelect(value);
+ });
+ }
+
+ onAddRootFolderModalClose = () => {
+ this.setState({ isAddNewRootFolderModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ includeNoChange,
+ onNewRootFolderSelect,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+RootFolderSelectInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onNewRootFolderSelect: PropTypes.func.isRequired
+};
+
+RootFolderSelectInput.defaultProps = {
+ includeNoChange: false
+};
+
+export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
new file mode 100644
index 000000000..b3dfcbd20
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
@@ -0,0 +1,137 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addRootFolder } from 'Store/Actions/rootFolderActions';
+import RootFolderSelectInput from './RootFolderSelectInput';
+
+const ADD_NEW_KEY = 'addNew';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ (state, { includeNoChange }) => includeNoChange,
+ (rootFolders, includeNoChange) => {
+ const values = _.map(rootFolders.items, (rootFolder) => {
+ return {
+ key: rootFolder.path,
+ value: rootFolder.path,
+ freeSpace: rootFolder.freeSpace
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ isDisabled: true
+ });
+ }
+
+ if (!values.length) {
+ values.push({
+ key: '',
+ value: '',
+ isDisabled: true,
+ isHidden: true
+ });
+ }
+
+ values.push({
+ key: ADD_NEW_KEY,
+ value: 'Add a new path'
+ });
+
+ return {
+ values,
+ isSaving: rootFolders.isSaving,
+ saveError: rootFolders.saveError
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchAddRootFolder(path) {
+ dispatch(addRootFolder({ path }));
+ }
+ };
+}
+
+class RootFolderSelectInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentWillMount() {
+ const {
+ value,
+ values,
+ onChange
+ } = this.props;
+
+ if (value == null && values[0].key === '') {
+ onChange({ name, value: '' });
+ }
+ }
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ values,
+ onChange
+ } = this.props;
+
+ if (!value || !_.some(values, (v) => v.key === value) || value === ADD_NEW_KEY) {
+ const defaultValue = values[0];
+
+ if (defaultValue.key === ADD_NEW_KEY) {
+ onChange({ name, value: '' });
+ } else {
+ onChange({ name, value: defaultValue.key });
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onNewRootFolderSelect = (path) => {
+ this.props.dispatchAddRootFolder(path);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchAddRootFolder,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+RootFolderSelectInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ dispatchAddRootFolder: PropTypes.func.isRequired
+};
+
+RootFolderSelectInputConnector.defaultProps = {
+ includeNoChange: false
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/RootFolderSelectInputOption.css
new file mode 100644
index 000000000..d8b44fcad
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.css
@@ -0,0 +1,20 @@
+.optionText {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 1 0 0;
+
+ &.isMobile {
+ display: block;
+
+ .freeSpace {
+ margin-left: 0;
+ }
+ }
+}
+
+.freeSpace {
+ margin-left: 15px;
+ color: $darkGray;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js
new file mode 100644
index 000000000..a4db9cd82
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import formatBytes from 'Utilities/Number/formatBytes';
+import EnhancedSelectInputOption from './EnhancedSelectInputOption';
+import styles from './RootFolderSelectInputOption.css';
+
+function RootFolderSelectInputOption(props) {
+ const {
+ value,
+ freeSpace,
+ isMobile,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
{value}
+
+ {
+ freeSpace != null &&
+
+ {formatBytes(freeSpace)} Free
+
+ }
+
+
+ );
+}
+
+RootFolderSelectInputOption.propTypes = {
+ value: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number,
+ isMobile: PropTypes.bool.isRequired
+};
+
+export default RootFolderSelectInputOption;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
new file mode 100644
index 000000000..0a8fa6ffe
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
@@ -0,0 +1,22 @@
+.selectedValue {
+ composes: selectedValue from './EnhancedSelectInputSelectedValue.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.path {
+ @add-mixin truncate;
+
+ flex: 1 0 0;
+}
+
+.freeSpace {
+ flex: 0 0 auto;
+ margin-left: 15px;
+ color: $gray;
+ text-align: right;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
new file mode 100644
index 000000000..ffd769254
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import styles from './RootFolderSelectInputSelectedValue.css';
+
+function RootFolderSelectInputSelectedValue(props) {
+ const {
+ value,
+ freeSpace,
+ includeFreeSpace,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {value}
+
+
+ {
+ freeSpace != null && includeFreeSpace &&
+
+ {formatBytes(freeSpace)} Free
+
+ }
+
+ );
+}
+
+RootFolderSelectInputSelectedValue.propTypes = {
+ value: PropTypes.string,
+ freeSpace: PropTypes.number,
+ includeFreeSpace: PropTypes.bool.isRequired
+};
+
+RootFolderSelectInputSelectedValue.defaultProps = {
+ includeFreeSpace: true
+};
+
+export default RootFolderSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/SelectInput.css b/frontend/src/Components/Form/SelectInput.css
new file mode 100644
index 000000000..5f1c10e83
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.css
@@ -0,0 +1,18 @@
+.select {
+ composes: input from 'Components/Form/Input.css';
+
+ padding: 0 11px;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.isDisabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js
new file mode 100644
index 000000000..113d50a09
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.js
@@ -0,0 +1,95 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './SelectInput.css';
+
+class SelectInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: event.target.value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ disabledClassName,
+ name,
+ value,
+ values,
+ isDisabled,
+ hasError,
+ hasWarning,
+ autoFocus,
+ onBlur
+ } = this.props;
+
+ return (
+
+ {
+ values.map((option) => {
+ const {
+ key,
+ value: optionValue,
+ ...otherOptionProps
+ } = option;
+
+ return (
+
+ {optionValue}
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+SelectInput.propTypes = {
+ className: PropTypes.string,
+ disabledClassName: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDisabled: PropTypes.bool,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ autoFocus: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onBlur: PropTypes.func
+};
+
+SelectInput.defaultProps = {
+ className: styles.select,
+ disabledClassName: styles.isDisabled,
+ isDisabled: false,
+ autoFocus: false
+};
+
+export default SelectInput;
diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/TagInput.css
new file mode 100644
index 000000000..e22109368
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.css
@@ -0,0 +1,78 @@
+.inputContainer {
+ composes: input from 'Components/Form/Input.css';
+
+ position: relative;
+ padding: 0;
+ min-height: 35px;
+ height: auto;
+
+ &.isFocused {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.tags {
+ flex: 0 0 auto;
+ max-width: 100%;
+}
+
+.input {
+ flex: 1 1 0%;
+ margin-left: 3px;
+ min-width: 20%;
+ max-width: 100%;
+ width: 0%;
+ height: 21px;
+ border: none;
+}
+
+.suggestionsContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.containerOpen {
+ .suggestionsContainer {
+ position: absolute;
+ right: -1px;
+ left: -1px;
+ z-index: 1;
+ overflow-y: auto;
+ margin-top: 1px;
+ max-height: 110px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ }
+}
+
+.suggestionsList {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.suggestion {
+ padding: 0 16px;
+ cursor: default;
+
+ &:hover {
+ background-color: $menuItemHoverBackgroundColor;
+ }
+}
+
+.suggestionHighlighted {
+ background-color: $menuItemHoverBackgroundColor;
+}
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
new file mode 100644
index 000000000..81682f842
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.js
@@ -0,0 +1,303 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import TagInputInput from './TagInputInput';
+import TagInputTag from './TagInputTag';
+import styles from './TagInput.css';
+
+function getTag(value, selectedIndex, suggestions, allowNew) {
+ if (selectedIndex == null && value) {
+ const existingTag = _.find(suggestions, { name: value });
+
+ if (existingTag) {
+ return existingTag;
+ } else if (allowNew) {
+ return { name: value };
+ }
+ } else if (selectedIndex != null) {
+ return suggestions[selectedIndex];
+ }
+}
+
+class TagInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ value: '',
+ suggestions: [],
+ isFocused: false
+ };
+
+ this._autosuggestRef = null;
+ }
+
+ componentWillUnmount() {
+ this.addTag.cancel();
+ }
+
+ //
+ // Control
+
+ _setAutosuggestRef = (ref) => {
+ this._autosuggestRef = ref;
+ }
+
+ getSuggestionValue({ name }) {
+ return name;
+ }
+
+ shouldRenderSuggestions = (value) => {
+ return value.length >= this.props.minQueryLength;
+ }
+
+ renderSuggestion({ name }) {
+ return name;
+ }
+
+ addTag = _.debounce((tag) => {
+ this.props.onTagAdd(tag);
+
+ this.setState({
+ value: '',
+ suggestions: []
+ });
+ }, 250, { leading: true, trailing: false })
+
+ //
+ // Listeners
+
+ onInputContainerPress = () => {
+ this._autosuggestRef.input.focus();
+ }
+
+ onInputChange = (event, { newValue, method }) => {
+ const value = _.isObject(newValue) ? newValue.name : newValue;
+
+ if (method === 'type') {
+ this.setState({ value });
+ }
+ }
+
+ onInputKeyDown = (event) => {
+ const {
+ tags,
+ allowNew,
+ delimiters,
+ onTagDelete
+ } = this.props;
+
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const keyCode = event.keyCode;
+
+ if (keyCode === 8 && !value.length) {
+ const index = tags.length - 1;
+
+ if (index >= 0) {
+ onTagDelete({ index, id: tags[index].id });
+ }
+
+ setTimeout(() => {
+ this.onSuggestionsFetchRequested({ value: '' });
+ });
+
+ event.preventDefault();
+ }
+
+ if (delimiters.includes(keyCode)) {
+ const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ this.addTag(tag);
+ event.preventDefault();
+ }
+ }
+ }
+
+ onInputFocus = () => {
+ this.setState({ isFocused: true });
+ }
+
+ onInputBlur = () => {
+ this.setState({ isFocused: false });
+
+ if (!this._autosuggestRef) {
+ return;
+ }
+
+ const {
+ allowNew
+ } = this.props;
+
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ this.addTag(tag);
+ }
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const lowerCaseValue = value.toLowerCase();
+
+ const {
+ tags,
+ tagList
+ } = this.props;
+
+ const suggestions = tagList.filter((tag) => {
+ return (
+ tag.name.toLowerCase().includes(lowerCaseValue) &&
+ !tags.some((t) => t.id === tag.id));
+ });
+
+ this.setState({ suggestions });
+ }
+
+ onSuggestionsClearRequested = () => {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+ }
+
+ onSuggestionSelected = (event, { suggestion }) => {
+ this.addTag(suggestion);
+ }
+
+ //
+ // Render
+
+ renderInputComponent = (inputProps) => {
+ const {
+ tags,
+ kind,
+ tagComponent,
+ onTagDelete
+ } = this.props;
+
+ return (
+
+ );
+ }
+
+ render() {
+ const {
+ className,
+ inputClassName,
+ placeholder,
+ hasError,
+ hasWarning
+ } = this.props;
+
+ const {
+ value,
+ suggestions,
+ isFocused
+ } = this.state;
+
+ const inputProps = {
+ className: inputClassName,
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onInputChange,
+ onKeyDown: this.onInputKeyDown,
+ onFocus: this.onInputFocus,
+ onBlur: this.onInputBlur
+ };
+
+ const theme = {
+ container: classNames(
+ className,
+ isFocused && styles.isFocused,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning,
+ ),
+ containerOpen: styles.containerOpen,
+ suggestionsContainer: styles.suggestionsContainer,
+ suggestionsList: styles.suggestionsList,
+ suggestion: styles.suggestion,
+ suggestionHighlighted: styles.suggestionHighlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+export const tagShape = {
+ id: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]).isRequired,
+ name: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired
+};
+
+TagInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ allowNew: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ placeholder: PropTypes.string.isRequired,
+ delimiters: PropTypes.arrayOf(PropTypes.number).isRequired,
+ minQueryLength: PropTypes.number.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ tagComponent: PropTypes.func.isRequired,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired
+};
+
+TagInput.defaultProps = {
+ className: styles.inputContainer,
+ inputClassName: styles.input,
+ allowNew: true,
+ kind: kinds.INFO,
+ placeholder: '',
+ // Tab, enter, space and comma
+ delimiters: [9, 13, 32, 188],
+ minQueryLength: 1,
+ tagComponent: TagInputTag
+};
+
+export default TagInput;
diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js
new file mode 100644
index 000000000..5265e9e4f
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputConnector.js
@@ -0,0 +1,156 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addTag } from 'Store/Actions/tagActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagInput from './TagInput';
+
+const validTagRegex = new RegExp('[^-_a-z0-9]', 'i');
+
+function isValidTag(tagName) {
+ try {
+ return !validTagRegex.test(tagName);
+ } catch (e) {
+ return false;
+ }
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ createTagsSelector(),
+ (tags, tagList) => {
+ const sortedTags = _.sortBy(tagList, 'label');
+ const filteredTagList = _.filter(sortedTags, (tag) => _.indexOf(tags, tag.id) === -1);
+
+ return {
+ tags: tags.reduce((acc, tag) => {
+ const matchingTag = _.find(tagList, { id: tag });
+
+ if (matchingTag) {
+ acc.push({
+ id: tag,
+ name: matchingTag.label
+ });
+ }
+
+ return acc;
+ }, []),
+
+ tagList: filteredTagList.map(({ id, label: name }) => {
+ return {
+ id,
+ name
+ };
+ }),
+
+ allTags: sortedTags
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ addTag
+};
+
+class TagInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ tags,
+ onChange
+ } = this.props;
+
+ if (value.length !== tags.length) {
+ onChange({ name, value: tags.map((tag) => tag.id) });
+ }
+ }
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ name,
+ value,
+ allTags
+ } = this.props;
+
+ if (!tag.id) {
+ const existingTag =_.some(allTags, { label: tag.name });
+
+ if (isValidTag(tag.name) && !existingTag) {
+ this.props.addTag({
+ tag: { label: tag.name },
+ onTagCreated: this.onTagCreated
+ });
+ }
+
+ return;
+ }
+
+ const newValue = value.slice();
+ newValue.push(tag.id);
+
+ this.props.onChange({ name, value: newValue });
+ }
+
+ onTagDelete = ({ index }) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ this.props.onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ onTagCreated = (tag) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = value.slice();
+ newValue.push(tag.id);
+
+ this.props.onChange({ name, value: newValue });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TagInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.object).isRequired,
+ allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onChange: PropTypes.func.isRequired,
+ addTag: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(TagInputConnector);
diff --git a/frontend/src/Components/Form/TagInputInput.css b/frontend/src/Components/Form/TagInputInput.css
new file mode 100644
index 000000000..182320b1a
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputInput.css
@@ -0,0 +1,6 @@
+.inputContainer {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 6px 16px;
+ cursor: default;
+}
diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js
new file mode 100644
index 000000000..8bd075774
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputInput.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import { tagShape } from './TagInput';
+import styles from './TagInputInput.css';
+
+class TagInputInput extends Component {
+
+ onMouseDown = (event) => {
+ event.preventDefault();
+
+ const {
+ isFocused,
+ onInputContainerPress
+ } = this.props;
+
+ if (isFocused) {
+ return;
+ }
+
+ onInputContainerPress();
+ }
+
+ render() {
+ const {
+ className,
+ tags,
+ inputProps,
+ kind,
+ tagComponent: TagComponent,
+ onTagDelete
+ } = this.props;
+
+ return (
+
+ {
+ tags.map((tag, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+TagInputInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ inputProps: PropTypes.object.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ isFocused: PropTypes.bool.isRequired,
+ tagComponent: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired,
+ onInputContainerPress: PropTypes.func.isRequired
+};
+
+TagInputInput.defaultProps = {
+ className: styles.inputContainer
+};
+
+export default TagInputInput;
diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js
new file mode 100644
index 000000000..ff1e0e2db
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputTag.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import { tagShape } from './TagInput';
+
+class TagInputTag extends Component {
+
+ //
+ // Listeners
+
+ onDelete = () => {
+ const {
+ index,
+ tag,
+ onDelete
+ } = this.props;
+
+ onDelete({
+ index,
+ id: tag.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ tag,
+ kind
+ } = this.props;
+
+ return (
+
+
+ {tag.name}
+
+
+ );
+ }
+}
+
+TagInputTag.propTypes = {
+ index: PropTypes.number.isRequired,
+ tag: PropTypes.shape(tagShape),
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ onDelete: PropTypes.func.isRequired
+};
+
+export default TagInputTag;
diff --git a/frontend/src/Components/Form/TextInput.css b/frontend/src/Components/Form/TextInput.css
new file mode 100644
index 000000000..7fb9f68cc
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.css
@@ -0,0 +1,19 @@
+.input {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.readOnly {
+ background-color: #eee;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.hasButton {
+ composes: hasButton from 'Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js
new file mode 100644
index 000000000..9feefa616
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.js
@@ -0,0 +1,188 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './TextInput.css';
+
+class TextInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._input = null;
+ this._selectionStart = null;
+ this._selectionEnd = null;
+ this._selectionTimeout = null;
+ this._isMouseTarget = false;
+ }
+
+ componentDidMount() {
+ window.addEventListener('mouseup', this.onDocumentMouseUp);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('mouseup', this.onDocumentMouseUp);
+
+ if (this._selectionTimeout) {
+ this._selectionTimeout = clearTimeout(this._selectionTimeout);
+ }
+ }
+
+ //
+ // Control
+
+ setInputRef = (ref) => {
+ this._input = ref;
+ }
+
+ selectionChange() {
+ if (this._selectionTimeout) {
+ this._selectionTimeout = clearTimeout(this._selectionTimeout);
+ }
+
+ this._selectionTimeout = setTimeout(() => {
+ const selectionStart = this._input.selectionStart;
+ const selectionEnd = this._input.selectionEnd;
+
+ const selectionChanged = (
+ this._selectionStart !== selectionStart ||
+ this._selectionEnd !== selectionEnd
+ );
+
+ this._selectionStart = selectionStart;
+ this._selectionEnd = selectionEnd;
+
+ if (this.props.onSelectionChange && selectionChanged) {
+ this.props.onSelectionChange(selectionStart, selectionEnd);
+ }
+ }, 10);
+ }
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ const {
+ name,
+ type,
+ onChange
+ } = this.props;
+
+ const payload = {
+ name,
+ value: event.target.value
+ };
+
+ // Also return the files for a file input type.
+
+ if (type === 'file') {
+ payload.files = event.target.files;
+ }
+
+ onChange(payload);
+ }
+
+ onFocus = (event) => {
+ if (this.props.onFocus) {
+ this.props.onFocus(event);
+ }
+
+ this.selectionChange();
+ }
+
+ onKeyUp = () => {
+ this.selectionChange();
+ }
+
+ onMouseDown = () => {
+ this._isMouseTarget = true;
+ }
+
+ onMouseUp = () => {
+ this.selectionChange();
+ }
+
+ onDocumentMouseUp = () => {
+ if (this._isMouseTarget) {
+ this.selectionChange();
+ }
+
+ this._isMouseTarget = false;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ type,
+ readOnly,
+ autoFocus,
+ placeholder,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ hasButton,
+ step,
+ onBlur
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+TextInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+ readOnly: PropTypes.bool,
+ autoFocus: PropTypes.bool,
+ placeholder: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ hasButton: PropTypes.bool,
+ step: PropTypes.number,
+ onChange: PropTypes.func.isRequired,
+ onFocus: PropTypes.func,
+ onBlur: PropTypes.func,
+ onSelectionChange: PropTypes.func
+};
+
+TextInput.defaultProps = {
+ className: styles.input,
+ type: 'text',
+ readOnly: false,
+ autoFocus: false,
+ value: ''
+};
+
+export default TextInput;
diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js
new file mode 100644
index 000000000..587066610
--- /dev/null
+++ b/frontend/src/Components/Form/TextTagInputConnector.js
@@ -0,0 +1,95 @@
+
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import split from 'Utilities/String/split';
+import TagInput from './TagInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ (tags) => {
+ const tagsArray = Array.isArray(tags) ? tags : split(tags);
+
+ return {
+ tags: tagsArray.reduce((result, tag) => {
+ if (tag) {
+ result.push({
+ id: tag,
+ name: tag
+ });
+ }
+
+ return result;
+ }, []),
+ valueArray: tagsArray
+ };
+ }
+ );
+}
+
+class TextTagInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ name,
+ valueArray,
+ onChange
+ } = this.props;
+
+ // Split and trim tags before adding them to the list, this will
+ // cleanse tags pasted in that had commas and spaces which leads
+ // to oddities with restrictions (as an example).
+
+ const newValue = [...valueArray];
+ const newTags = split(tag.name);
+
+ newTags.forEach((newTag) => {
+ newValue.push(newTag.trim());
+ });
+
+ onChange({ name, value: newValue.join(',') });
+ }
+
+ onTagDelete = ({ index }) => {
+ const {
+ name,
+ valueArray,
+ onChange
+ } = this.props;
+
+ const newValue = [...valueArray];
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue.join(',')
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TextTagInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ valueArray: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, null)(TextTagInputConnector);
diff --git a/frontend/src/Components/HeartRating.css b/frontend/src/Components/HeartRating.css
new file mode 100644
index 000000000..705adfcae
--- /dev/null
+++ b/frontend/src/Components/HeartRating.css
@@ -0,0 +1,4 @@
+.heart {
+ margin-right: 5px;
+ color: $themeRed;
+}
diff --git a/frontend/src/Components/HeartRating.js b/frontend/src/Components/HeartRating.js
new file mode 100644
index 000000000..98c3f817e
--- /dev/null
+++ b/frontend/src/Components/HeartRating.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import styles from './HeartRating.css';
+
+function HeartRating({ rating, iconSize }) {
+ return (
+
+
+
+ {rating * 10}%
+
+ );
+}
+
+HeartRating.propTypes = {
+ rating: PropTypes.number.isRequired,
+ iconSize: PropTypes.number.isRequired
+};
+
+HeartRating.defaultProps = {
+ iconSize: 14
+};
+
+export default HeartRating;
diff --git a/frontend/src/Components/Icon.css b/frontend/src/Components/Icon.css
new file mode 100644
index 000000000..df1ff5327
--- /dev/null
+++ b/frontend/src/Components/Icon.css
@@ -0,0 +1,27 @@
+.danger {
+ color: $dangerColor;
+}
+
+.default {
+ color: inherit;
+}
+
+.disabled {
+ color: $disabledColor;
+}
+
+.info {
+ color: $infoColor;
+}
+
+.pink {
+ color: $pink;
+}
+
+.success {
+ color: $successColor;
+}
+
+.warning {
+ color: $warningColor;
+}
diff --git a/frontend/src/Components/Icon.js b/frontend/src/Components/Icon.js
new file mode 100644
index 000000000..d1005c6e8
--- /dev/null
+++ b/frontend/src/Components/Icon.js
@@ -0,0 +1,69 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { kinds } from 'Helpers/Props';
+import classNames from 'classnames';
+import styles from './Icon.css';
+
+class Icon extends React.PureComponent {
+ render() {
+ const {
+ containerClassName,
+ className,
+ name,
+ kind,
+ size,
+ title,
+ isSpinning,
+ ...otherProps
+ } = this.props;
+
+ const icon = (
+
+ );
+
+ if (title) {
+ return (
+
+ {icon}
+
+ );
+ }
+
+ return icon;
+ }
+}
+
+Icon.propTypes = {
+ containerClassName: PropTypes.string,
+ className: PropTypes.string,
+ name: PropTypes.object.isRequired,
+ kind: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ title: PropTypes.string,
+ isSpinning: PropTypes.bool.isRequired,
+ fixedWidth: PropTypes.bool.isRequired
+};
+
+Icon.defaultProps = {
+ kind: kinds.DEFAULT,
+ size: 14,
+ isSpinning: false,
+ fixedWidth: false
+};
+
+export default Icon;
diff --git a/frontend/src/Components/Label.css b/frontend/src/Components/Label.css
new file mode 100644
index 000000000..2cad859ce
--- /dev/null
+++ b/frontend/src/Components/Label.css
@@ -0,0 +1,111 @@
+.label {
+ display: inline-block;
+ margin: 2px;
+ border: 1px solid;
+ border-radius: 2px;
+ color: $white;
+ text-align: center;
+ white-space: nowrap;
+ line-height: 1;
+ cursor: default;
+}
+
+/** Kinds **/
+
+.danger {
+ border-color: $dangerColor;
+ background-color: $dangerColor;
+
+ &.outline {
+ color: $dangerColor;
+ }
+}
+
+.default {
+ border-color: $themeLightColor;
+ background-color: $themeLightColor;
+
+ &.outline {
+ color: $themeLightColor;
+ }
+}
+
+.disabled {
+ border-color: $disabledColor;
+ background-color: $disabledColor;
+
+ &.outline {
+ color: $disabledColor;
+ }
+}
+
+.info {
+ border-color: $infoColor;
+ background-color: $infoColor;
+
+ &.outline {
+ color: $infoColor;
+ }
+}
+
+.inverse {
+ border-color: $lightGray;
+ background-color: $lightGray;
+ color: $defaultColor;
+
+ &.outline {
+ background-color: $defaultColor !important;
+ color: $lightGray;
+ }
+}
+
+.primary {
+ border-color: $primaryColor;
+ background-color: $primaryColor;
+
+ &.outline {
+ color: $primaryColor;
+ }
+}
+
+.success {
+ border-color: $successColor;
+ background-color: $successColor;
+
+ &.outline {
+ color: $successColor;
+ }
+}
+
+.warning {
+ border-color: $warningColor;
+ background-color: $warningColor;
+
+ &.outline {
+ color: $warningColor;
+ }
+}
+
+/** Sizes **/
+
+.small {
+ padding: 1px 3px;
+ font-size: 11px;
+}
+
+.medium {
+ padding: 2px 5px;
+ font-size: 12px;
+}
+
+.large {
+ padding: 3px 7px;
+ font-weight: bold;
+ font-size: 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..54789de52
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Clipboard from 'clipboard';
+import { icons, kinds } from 'Helpers/Props';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import Icon from 'Components/Icon';
+import FormInputButton from 'Components/Form/FormInputButton';
+import styles from './ClipboardButton.css';
+
+class ClipboardButton extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._id = getUniqueElememtId();
+ this._successTimeout = null;
+
+ this.state = {
+ showSuccess: false,
+ showError: false
+ };
+ }
+
+ componentDidMount() {
+ this._clipboard = new Clipboard(`#${this._id}`, {
+ text: () => this.props.value
+ });
+
+ this._clipboard.on('success', this.onSuccess);
+ }
+
+ componentDidUpdate() {
+ const {
+ showSuccess,
+ showError
+ } = this.state;
+
+ if (showSuccess || showError) {
+ this._testResultTimeout = setTimeout(this.resetState, 3000);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._clipboard) {
+ this._clipboard.destroy();
+ }
+ }
+
+ //
+ // Control
+
+ resetState = () => {
+ this.setState({
+ showSuccess: false,
+ showError: false
+ });
+ }
+
+ //
+ // Listeners
+
+ onSuccess = () => {
+ this.setState({
+ showSuccess: true
+ });
+ }
+
+ onError = () => {
+ this.setState({
+ showError: true
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ ...otherProps
+ } = this.props;
+
+ const {
+ showSuccess,
+ showError
+ } = this.state;
+
+ const showStateIcon = showSuccess || showError;
+ const iconName = showError ? icons.DANGER : icons.CHECK;
+ const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
+
+ return (
+
+
+ {
+ showSuccess &&
+
+
+
+ }
+
+ {
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ClipboardButton.propTypes = {
+ value: PropTypes.string.isRequired
+};
+
+export default ClipboardButton;
diff --git a/frontend/src/Components/Link/IconButton.css b/frontend/src/Components/Link/IconButton.css
new file mode 100644
index 000000000..2c85173a1
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.css
@@ -0,0 +1,21 @@
+.button {
+ composes: link from 'Components/Link/Link.css';
+
+ display: inline-block;
+ margin: 0 2px;
+ width: 22px;
+ border-radius: 4px;
+ background-color: transparent;
+ text-align: center;
+ font-size: inherit;
+
+ &:hover {
+ border: none;
+ background-color: inherit;
+ color: $iconButtonHoverColor;
+ }
+
+ &.isDisabled {
+ color: $iconButtonDisabledColor;
+ }
+}
diff --git a/frontend/src/Components/Link/IconButton.js b/frontend/src/Components/Link/IconButton.js
new file mode 100644
index 000000000..26aacb0bf
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import Icon from 'Components/Icon';
+import Link from './Link';
+import styles from './IconButton.css';
+
+function IconButton(props) {
+ const {
+ className,
+ iconClassName,
+ name,
+ kind,
+ size,
+ isSpinning,
+ isDisabled,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+IconButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ iconClassName: PropTypes.string,
+ kind: PropTypes.string,
+ name: PropTypes.object.isRequired,
+ size: PropTypes.number,
+ isSpinning: PropTypes.bool,
+ isDisabled: PropTypes.bool
+};
+
+IconButton.defaultProps = {
+ className: styles.button,
+ size: 12
+};
+
+export default IconButton;
diff --git a/frontend/src/Components/Link/Link.css b/frontend/src/Components/Link/Link.css
new file mode 100644
index 000000000..ff0ed8d0c
--- /dev/null
+++ b/frontend/src/Components/Link/Link.css
@@ -0,0 +1,24 @@
+.link {
+ margin: 0;
+ padding: 0;
+ outline: none;
+ border: 0;
+ background: none;
+ color: inherit;
+ text-align: inherit;
+ text-decoration: none;
+ cursor: pointer;
+
+ &:global(.isDisabled) {
+ cursor: default;
+ }
+}
+
+.to {
+ color: $linkColor;
+
+ &:hover {
+ color: $linkHoverColor;
+ text-decoration: underline;
+ }
+}
diff --git a/frontend/src/Components/Link/Link.js b/frontend/src/Components/Link/Link.js
new file mode 100644
index 000000000..6f3caaef6
--- /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.Radarr.urlBase)) {
+ el = RouterLink;
+ linkProps.to = to;
+ linkProps.target = target;
+ } else {
+ el = RouterLink;
+ linkProps.to = `${window.Radarr.urlBase}/${to.replace(/^\//, '')}`;
+ linkProps.target = target;
+ }
+ }
+
+ if (el === 'button' || el === 'input') {
+ linkProps.type = otherProps.type || 'button';
+ linkProps.disabled = isDisabled;
+ }
+
+ linkProps.className = classNames(
+ className,
+ styles.link,
+ to && styles.to,
+ isDisabled && 'isDisabled'
+ );
+
+ const props = {
+ ...otherProps,
+ ...linkProps
+ };
+
+ props.onClick = this.onClick;
+
+ return (
+ React.createElement(el, props)
+ );
+ }
+}
+
+Link.propTypes = {
+ className: PropTypes.string,
+ component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+ to: PropTypes.string,
+ target: PropTypes.string,
+ isDisabled: PropTypes.bool,
+ noRouter: PropTypes.bool,
+ onPress: PropTypes.func
+};
+
+Link.defaultProps = {
+ component: 'button',
+ noRouter: false
+};
+
+export default Link;
diff --git a/frontend/src/Components/Link/SpinnerButton.css b/frontend/src/Components/Link/SpinnerButton.css
new file mode 100644
index 000000000..cfccd0f06
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerButton.css
@@ -0,0 +1,37 @@
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ position: relative;
+}
+
+.spinnerContainer {
+ position: absolute;
+ top: 50%;
+ left: -100%;
+ display: inline-flex;
+ visibility: hidden;
+ transition: left $defaultSpeed;
+ transform: translateX(-50%) translateY(-50%);
+}
+
+.spinner {
+ z-index: 1;
+}
+
+.label {
+ position: relative;
+ left: 0;
+ transition: left $defaultSpeed, opacity $defaultSpeed;
+}
+
+.isSpinning {
+ .spinnerContainer {
+ left: 50%;
+ visibility: visible;
+ }
+
+ .label {
+ left: 100%;
+ visibility: hidden;
+ }
+}
diff --git a/frontend/src/Components/Link/SpinnerButton.js b/frontend/src/Components/Link/SpinnerButton.js
new file mode 100644
index 000000000..1507220d6
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerButton.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Button from './Button';
+import styles from './SpinnerButton.css';
+
+function SpinnerButton(props) {
+ const {
+ className,
+ isSpinning,
+ isDisabled,
+ spinnerIcon,
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+SpinnerButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ isSpinning: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool,
+ spinnerIcon: PropTypes.object.isRequired,
+ children: PropTypes.node
+};
+
+SpinnerButton.defaultProps = {
+ className: styles.button,
+ spinnerIcon: icons.SPINNER
+};
+
+export default SpinnerButton;
diff --git a/frontend/src/Components/Link/SpinnerErrorButton.css b/frontend/src/Components/Link/SpinnerErrorButton.css
new file mode 100644
index 000000000..5f4e68545
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerErrorButton.css
@@ -0,0 +1,23 @@
+.iconContainer {
+ composes: spinnerContainer from 'Components/Link/SpinnerButton.css';
+}
+
+.icon {
+ z-index: 1;
+}
+
+.label {
+ composes: label from 'Components/Link/SpinnerButton.css';
+}
+
+.showIcon {
+ .iconContainer {
+ left: 50%;
+ visibility: visible;
+ }
+
+ .label {
+ left: 100%;
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js
new file mode 100644
index 000000000..0575db094
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerErrorButton.js
@@ -0,0 +1,162 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import styles from './SpinnerErrorButton.css';
+
+function getTestResult(error) {
+ if (!error) {
+ return {
+ wasSuccessful: true,
+ hasWarning: false,
+ hasError: false
+ };
+ }
+
+ if (error.status !== 400) {
+ return {
+ wasSuccessful: false,
+ hasWarning: false,
+ hasError: true
+ };
+ }
+
+ const failures = error.responseJSON;
+
+ const hasWarning = _.some(failures, { isWarning: true });
+ const hasError = _.some(failures, (failure) => !failure.isWarning);
+
+ return {
+ wasSuccessful: false,
+ hasWarning,
+ hasError
+ };
+}
+
+class SpinnerErrorButton extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._testResultTimeout = null;
+
+ this.state = {
+ wasSuccessful: false,
+ hasWarning: false,
+ hasError: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSpinning,
+ error
+ } = this.props;
+
+ if (prevProps.isSpinning && !isSpinning) {
+ const testResult = getTestResult(error);
+
+ this.setState(testResult, () => {
+ const {
+ wasSuccessful,
+ hasWarning,
+ hasError
+ } = testResult;
+
+ if (wasSuccessful || hasWarning || hasError) {
+ this._testResultTimeout = setTimeout(this.resetState, 3000);
+ }
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._testResultTimeout) {
+ clearTimeout(this._testResultTimeout);
+ }
+ }
+
+ //
+ // Control
+
+ resetState = () => {
+ this.setState({
+ wasSuccessful: false,
+ hasWarning: false,
+ hasError: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSpinning,
+ error,
+ children,
+ ...otherProps
+ } = this.props;
+
+ const {
+ wasSuccessful,
+ hasWarning,
+ hasError
+ } = this.state;
+
+ const showIcon = wasSuccessful || hasWarning || hasError;
+
+ let iconName = icons.CHECK;
+ let iconKind = kinds.SUCCESS;
+
+ if (hasWarning) {
+ iconName = icons.WARNING;
+ iconKind = kinds.WARNING;
+ }
+
+ if (hasError) {
+ iconName = icons.DANGER;
+ iconKind = kinds.DANGER;
+ }
+
+ return (
+
+
+ {
+ showIcon &&
+
+
+
+ }
+
+ {
+
+ {
+ children
+ }
+
+ }
+
+
+ );
+ }
+}
+
+SpinnerErrorButton.propTypes = {
+ isSpinning: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ children: PropTypes.node.isRequired
+};
+
+export default SpinnerErrorButton;
diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js
new file mode 100644
index 000000000..a804fafc5
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerIconButton.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from './IconButton';
+
+function SpinnerIconButton(props) {
+ const {
+ name,
+ spinningName,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+}
+
+SpinnerIconButton.propTypes = {
+ name: PropTypes.object.isRequired,
+ spinningName: PropTypes.object.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isSpinning: PropTypes.bool.isRequired
+};
+
+SpinnerIconButton.defaultProps = {
+ spinningName: icons.SPINNER,
+ isDisabled: false,
+ isSpinning: false
+};
+
+export default SpinnerIconButton;
diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css
new file mode 100644
index 000000000..fd224b1d6
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingIndicator.css
@@ -0,0 +1,49 @@
+.loading {
+ margin-top: 20px;
+ text-align: center;
+}
+
+.rippleContainer {
+ position: relative;
+ display: inline-block;
+}
+
+.ripple:nth-child(0) {
+ animation-delay: -0.8s;
+}
+
+.ripple:nth-child(1) {
+ animation-delay: -0.6s;
+}
+
+.ripple:nth-child(2) {
+ animation-delay: -0.4s;
+}
+
+.ripple:nth-child(3) {
+ animation-delay: -0.2s;
+}
+
+.ripple {
+ position: absolute;
+ border: 2px solid #3a3f51;
+ border-radius: 100%;
+ animation: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8);
+ animation-fill-mode: both;
+}
+
+@keyframes rippleContainer {
+ 0% {
+ opacity: 1;
+ transform: scale(0.1);
+ }
+
+ 70% {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/Components/Loading/LoadingIndicator.js b/frontend/src/Components/Loading/LoadingIndicator.js
new file mode 100644
index 000000000..5f9a15b1a
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingIndicator.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './LoadingIndicator.css';
+
+function LoadingIndicator({ className, size }) {
+ const sizeInPx = `${size}px`;
+ const width = sizeInPx;
+ const height = sizeInPx;
+
+ return (
+
+ );
+}
+
+LoadingIndicator.propTypes = {
+ className: PropTypes.string,
+ size: PropTypes.number
+};
+
+LoadingIndicator.defaultProps = {
+ className: styles.loading,
+ size: 50
+};
+
+export default LoadingIndicator;
diff --git a/frontend/src/Components/Loading/LoadingMessage.css b/frontend/src/Components/Loading/LoadingMessage.css
new file mode 100644
index 000000000..a7b39e76f
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingMessage.css
@@ -0,0 +1,6 @@
+.loadingMessage {
+ margin: 50px 10px 0;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
diff --git a/frontend/src/Components/Loading/LoadingMessage.js b/frontend/src/Components/Loading/LoadingMessage.js
new file mode 100644
index 000000000..db6cb2b56
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingMessage.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import styles from './LoadingMessage.css';
+
+const messages = [
+ 'Welcome to Radarr Aphrodite Preview. Enjoy'
+ // TODO Add some messages here
+];
+
+function LoadingMessage() {
+ const index = Math.floor(Math.random() * messages.length);
+ const message = messages[index];
+
+ return (
+
+ {message}
+
+ );
+}
+
+export default LoadingMessage;
diff --git a/frontend/src/Components/Measure.js b/frontend/src/Components/Measure.js
new file mode 100644
index 000000000..a2f113de7
--- /dev/null
+++ b/frontend/src/Components/Measure.js
@@ -0,0 +1,38 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactMeasure from 'react-measure';
+
+class Measure extends Component {
+
+ //
+ // Lifecycle
+
+ componentWillUnmount() {
+ this.onMeasure.cancel();
+ }
+
+ //
+ // Listeners
+
+ onMeasure = _.debounce((payload) => {
+ this.props.onMeasure(payload);
+ }, 250, { leading: true, trailing: false })
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+Measure.propTypes = {
+ onMeasure: PropTypes.func.isRequired
+};
+
+export default Measure;
diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css
new file mode 100644
index 000000000..34991aed9
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenu.css
@@ -0,0 +1,9 @@
+.filterMenu {
+ composes: menu from './Menu.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .filterMenu {
+ margin-right: 10px;
+ }
+}
diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js
new file mode 100644
index 000000000..d37876c22
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenu.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FilterMenuContent from './FilterMenuContent';
+import Menu from './Menu';
+import ToolbarMenuButton from './ToolbarMenuButton';
+import styles from './FilterMenu.css';
+
+class FilterMenu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isFilterModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCustomFiltersPress = () => {
+ this.setState({ isFilterModalOpen: true });
+ }
+
+ onFiltersModalClose = () => {
+ this.setState({ isFilterModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render(props) {
+ const {
+ className,
+ isDisabled,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ buttonComponent: ButtonComponent,
+ filterModalConnectorComponent: FilterModalConnectorComponent,
+ filterModalConnectorComponentProps,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const showCustomFilters = !!FilterModalConnectorComponent;
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ showCustomFilters &&
+
+ }
+
+ );
+ }
+}
+
+FilterMenu.propTypes = {
+ className: PropTypes.string,
+ isDisabled: PropTypes.bool.isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ buttonComponent: PropTypes.func.isRequired,
+ filterModalConnectorComponent: PropTypes.func,
+ filterModalConnectorComponentProps: PropTypes.object,
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+FilterMenu.defaultProps = {
+ className: styles.filterMenu,
+ isDisabled: false,
+ buttonComponent: ToolbarMenuButton
+};
+
+export default FilterMenu;
diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js
new file mode 100644
index 000000000..7463e2c9e
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenuContent.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuContent from './MenuContent';
+import FilterMenuItem from './FilterMenuItem';
+import MenuItem from './MenuItem';
+import MenuItemSeparator from './MenuItemSeparator';
+
+class FilterMenuContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ showCustomFilters,
+ onFilterSelect,
+ onCustomFiltersPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {
+ filters.map((filter) => {
+ return (
+
+ {filter.label}
+
+ );
+ })
+ }
+
+ {
+ customFilters.map((filter) => {
+ return (
+
+ {filter.label}
+
+ );
+ })
+ }
+
+ {
+ showCustomFilters &&
+
+ }
+
+ {
+ showCustomFilters &&
+
+ Custom Filters
+
+ }
+
+ );
+ }
+}
+
+FilterMenuContent.propTypes = {
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ showCustomFilters: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onCustomFiltersPress: PropTypes.func.isRequired
+};
+
+FilterMenuContent.defaultProps = {
+ showCustomFilters: false
+};
+
+export default FilterMenuContent;
diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js
new file mode 100644
index 000000000..d2c495187
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenuItem.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import SelectedMenuItem from './SelectedMenuItem';
+
+class FilterMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ filterKey,
+ onPress
+ } = this.props;
+
+ onPress(filterKey);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filterKey,
+ selectedFilterKey,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+FilterMenuItem.propTypes = {
+ filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default FilterMenuItem;
diff --git a/frontend/src/Components/Menu/Menu.css b/frontend/src/Components/Menu/Menu.css
new file mode 100644
index 000000000..9cce48fee
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.css
@@ -0,0 +1,7 @@
+.tether {
+ z-index: 2000;
+}
+
+.menu {
+ position: relative;
+}
diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js
new file mode 100644
index 000000000..da778bb7a
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.js
@@ -0,0 +1,207 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import { align } from 'Helpers/Props';
+import styles from './Menu.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [align.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'top right',
+ targetAttachment: 'bottom right'
+ },
+
+ [align.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'top left',
+ targetAttachment: 'bottom left'
+ }
+};
+
+class Menu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMenuOpen: false,
+ maxHeight: 0
+ };
+ }
+
+ componentDidMount() {
+ this.setMaxHeight();
+ }
+
+ componentWillUnmount() {
+ this._removeListener();
+ }
+
+ //
+ // Control
+
+ getMaxHeight() {
+ if (!this.props.enforceMaxHeight) {
+ return;
+ }
+
+ const menu = ReactDOM.findDOMNode(this.refs.menu);
+
+ if (!menu) {
+ return;
+ }
+
+ const { bottom } = menu.getBoundingClientRect();
+ const maxHeight = window.innerHeight - bottom;
+
+ return maxHeight;
+ }
+
+ setMaxHeight() {
+ this.setState({
+ maxHeight: this.getMaxHeight()
+ });
+ }
+
+ _addListener() {
+ // Listen to resize events on the window and scroll events
+ // on all elements to ensure the menu is the best size possible.
+ // Listen for click events on the window to support closing the
+ // menu on clicks outside.
+
+ window.addEventListener('resize', this.onWindowResize);
+ window.addEventListener('scroll', this.onWindowScroll, { capture: true });
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('resize', this.onWindowResize);
+ window.removeEventListener('scroll', this.onWindowScroll, { capture: true });
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const menu = ReactDOM.findDOMNode(this.refs.menu);
+ const menuContent = ReactDOM.findDOMNode(this.refs.menuContent);
+
+ if (!menu) {
+ return;
+ }
+
+ if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) {
+ this.setState({ isMenuOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onWindowResize = () => {
+ this.setMaxHeight();
+ }
+
+ onWindowScroll = () => {
+ this.setMaxHeight();
+ }
+
+ onMenuButtonPress = () => {
+ const state = {
+ isMenuOpen: !this.state.isMenuOpen
+ };
+
+ if (this.state.isMenuOpen) {
+ this._removeListener();
+ } else {
+ state.maxHeight = this.getMaxHeight();
+ this._addListener();
+ }
+
+ this.setState(state);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ alignMenu
+ } = this.props;
+
+ const {
+ maxHeight,
+ isMenuOpen
+ } = this.state;
+
+ const childrenArray = React.Children.toArray(children);
+ const button = React.cloneElement(
+ childrenArray[0],
+ {
+ onPress: this.onMenuButtonPress
+ }
+ );
+
+ const content = React.cloneElement(
+ childrenArray[1],
+ {
+ ref: 'menuContent',
+ alignMenu,
+ maxHeight,
+ isOpen: isMenuOpen
+ }
+ );
+
+ return (
+
+
+ {button}
+
+
+ {
+ isMenuOpen &&
+ content
+ }
+
+ );
+ }
+}
+
+Menu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]),
+ enforceMaxHeight: PropTypes.bool.isRequired
+};
+
+Menu.defaultProps = {
+ className: styles.menu,
+ alignMenu: align.LEFT,
+ enforceMaxHeight: true
+};
+
+export default Menu;
diff --git a/frontend/src/Components/Menu/MenuButton.css b/frontend/src/Components/Menu/MenuButton.css
new file mode 100644
index 000000000..38812cfb7
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.css
@@ -0,0 +1,21 @@
+.menuButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+}
+
+.isDisabled {
+ color: $disabledColor;
+
+ pointer-events: none;
+}
diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js
new file mode 100644
index 000000000..477334a1d
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import Link from 'Components/Link/Link';
+import styles from './MenuButton.css';
+
+class MenuButton extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ isDisabled,
+ onPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+MenuButton.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onPress: PropTypes.func
+};
+
+MenuButton.defaultProps = {
+ className: styles.menuButton,
+ isDisabled: false
+};
+
+export default MenuButton;
diff --git a/frontend/src/Components/Menu/MenuContent.css b/frontend/src/Components/Menu/MenuContent.css
new file mode 100644
index 000000000..0acc07390
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.css
@@ -0,0 +1,11 @@
+.menuContent {
+ display: flex;
+ flex-direction: column;
+ background-color: $toolbarMenuItemBackgroundColor;
+ line-height: 20px;
+}
+
+.scroller {
+ display: flex;
+ flex-direction: column;
+}
diff --git a/frontend/src/Components/Menu/MenuContent.js b/frontend/src/Components/Menu/MenuContent.js
new file mode 100644
index 000000000..1acacf80f
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './MenuContent.css';
+
+class MenuContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ maxHeight
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+MenuContent.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ maxHeight: PropTypes.number
+};
+
+MenuContent.defaultProps = {
+ className: styles.menuContent
+};
+
+export default MenuContent;
diff --git a/frontend/src/Components/Menu/MenuItem.css b/frontend/src/Components/Menu/MenuItem.css
new file mode 100644
index 000000000..bae1a649c
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItem.css
@@ -0,0 +1,19 @@
+.menuItem {
+ @add-mixin truncate;
+
+ display: block;
+ flex-shrink: 0;
+ padding: 10px 20px;
+ min-width: 150px;
+ max-width: 250px;
+ background-color: $toolbarMenuItemBackgroundColor;
+ color: $menuItemColor;
+ line-height: 20px;
+
+ &:hover,
+ &:focus {
+ background-color: $toolbarMenuItemHoverBackgroundColor;
+ color: $menuItemHoverColor;
+ text-decoration: none;
+ }
+}
diff --git a/frontend/src/Components/Menu/MenuItem.js b/frontend/src/Components/Menu/MenuItem.js
new file mode 100644
index 000000000..ff083450b
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItem.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './MenuItem.css';
+
+class MenuItem extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+MenuItem.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+MenuItem.defaultProps = {
+ className: styles.menuItem
+};
+
+export default MenuItem;
diff --git a/frontend/src/Components/Menu/MenuItemSeparator.css b/frontend/src/Components/Menu/MenuItemSeparator.css
new file mode 100644
index 000000000..a867e3153
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItemSeparator.css
@@ -0,0 +1,5 @@
+.separator {
+ overflow: hidden;
+ height: 1px;
+ background-color: $themeDarkColor;
+}
diff --git a/frontend/src/Components/Menu/MenuItemSeparator.js b/frontend/src/Components/Menu/MenuItemSeparator.js
new file mode 100644
index 000000000..e586670c9
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItemSeparator.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import styles from './MenuItemSeparator.css';
+
+function MenuItemSeparator() {
+ return (
+
+ );
+}
+
+export default MenuItemSeparator;
diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css
new file mode 100644
index 000000000..e6954f600
--- /dev/null
+++ b/frontend/src/Components/Menu/PageMenuButton.css
@@ -0,0 +1,11 @@
+.menuButton {
+ composes: menuButton from './MenuButton.css';
+
+ &:hover {
+ color: #666;
+ }
+}
+
+.label {
+ margin-left: 5px;
+}
diff --git a/frontend/src/Components/Menu/PageMenuButton.js b/frontend/src/Components/Menu/PageMenuButton.js
new file mode 100644
index 000000000..abbfc98f8
--- /dev/null
+++ b/frontend/src/Components/Menu/PageMenuButton.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import MenuButton from 'Components/Menu/MenuButton';
+import styles from './PageMenuButton.css';
+
+function PageMenuButton(props) {
+ const {
+ iconName,
+ text,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+ {text}
+
+
+ );
+}
+
+PageMenuButton.propTypes = {
+ iconName: PropTypes.object.isRequired,
+ text: PropTypes.string
+};
+
+export default PageMenuButton;
diff --git a/frontend/src/Components/Menu/SelectedMenuItem.css b/frontend/src/Components/Menu/SelectedMenuItem.css
new file mode 100644
index 000000000..739419d69
--- /dev/null
+++ b/frontend/src/Components/Menu/SelectedMenuItem.css
@@ -0,0 +1,15 @@
+.item {
+ display: flex;
+ justify-content: space-between;
+ white-space: nowrap;
+}
+
+.isSelected {
+ visibility: visible;
+ margin-left: 20px;
+}
+
+.isNotSelected {
+ visibility: hidden;
+ margin-left: 20px;
+}
diff --git a/frontend/src/Components/Menu/SelectedMenuItem.js b/frontend/src/Components/Menu/SelectedMenuItem.js
new file mode 100644
index 000000000..8b0805c57
--- /dev/null
+++ b/frontend/src/Components/Menu/SelectedMenuItem.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import MenuItem from './MenuItem';
+import styles from './SelectedMenuItem.css';
+
+class SelectedMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ onPress
+ } = this.props;
+
+ onPress(name);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ selectedIconName,
+ isSelected,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+
+
+ );
+ }
+}
+
+SelectedMenuItem.propTypes = {
+ name: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ selectedIconName: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+SelectedMenuItem.defaultProps = {
+ selectedIconName: icons.CHECK
+};
+
+export default SelectedMenuItem;
diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js
new file mode 100644
index 000000000..a9a6a184e
--- /dev/null
+++ b/frontend/src/Components/Menu/SortMenu.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Menu from 'Components/Menu/Menu';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+
+function SortMenu(props) {
+ const {
+ className,
+ children,
+ isDisabled,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+SortMenu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool.isRequired
+};
+
+SortMenu.defaultProps = {
+ isDisabled: false
+};
+
+export default SortMenu;
diff --git a/frontend/src/Components/Menu/SortMenuItem.js b/frontend/src/Components/Menu/SortMenuItem.js
new file mode 100644
index 000000000..e35864ae6
--- /dev/null
+++ b/frontend/src/Components/Menu/SortMenuItem.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import SelectedMenuItem from './SelectedMenuItem';
+
+function SortMenuItem(props) {
+ const {
+ name,
+ sortKey,
+ sortDirection,
+ ...otherProps
+ } = props;
+
+ const isSelected = name === sortKey;
+
+ return (
+
+ );
+}
+
+SortMenuItem.propTypes = {
+ name: PropTypes.string,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ onPress: PropTypes.func.isRequired
+};
+
+SortMenuItem.defaultProps = {
+ name: null
+};
+
+export default SortMenuItem;
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css
new file mode 100644
index 000000000..c8a905e17
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.css
@@ -0,0 +1,11 @@
+.menuButton {
+ composes: menuButton from './MenuButton.css';
+
+ width: $toolbarButtonWidth;
+ height: $toolbarHeight;
+ text-align: center;
+}
+
+.label {
+ composes: label from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js
new file mode 100644
index 000000000..b80d6eaa3
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import MenuButton from 'Components/Menu/MenuButton';
+import styles from './ToolbarMenuButton.css';
+
+function ToolbarMenuButton(props) {
+ const {
+ iconName,
+ text,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+ToolbarMenuButton.propTypes = {
+ iconName: PropTypes.object.isRequired,
+ text: PropTypes.string
+};
+
+export default ToolbarMenuButton;
diff --git a/frontend/src/Components/Menu/ViewMenu.js b/frontend/src/Components/Menu/ViewMenu.js
new file mode 100644
index 000000000..60c77e003
--- /dev/null
+++ b/frontend/src/Components/Menu/ViewMenu.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Menu from 'Components/Menu/Menu';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+
+function ViewMenu(props) {
+ const {
+ children,
+ isDisabled,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+ViewMenu.propTypes = {
+ children: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool.isRequired
+};
+
+ViewMenu.defaultProps = {
+ isDisabled: false
+};
+
+export default ViewMenu;
diff --git a/frontend/src/Components/Menu/ViewMenuItem.js b/frontend/src/Components/Menu/ViewMenuItem.js
new file mode 100644
index 000000000..d355d6e94
--- /dev/null
+++ b/frontend/src/Components/Menu/ViewMenuItem.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectedMenuItem from './SelectedMenuItem';
+
+function ViewMenuItem(props) {
+ const {
+ name,
+ selectedView,
+ ...otherProps
+ } = props;
+
+ const isSelected = name === selectedView;
+
+ return (
+
+ );
+}
+
+ViewMenuItem.propTypes = {
+ name: PropTypes.string,
+ selectedView: PropTypes.string.isRequired
+};
+
+export default ViewMenuItem;
diff --git a/frontend/src/Components/Modal/ConfirmModal.js b/frontend/src/Components/Modal/ConfirmModal.js
new file mode 100644
index 000000000..5bb783d43
--- /dev/null
+++ b/frontend/src/Components/Modal/ConfirmModal.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function ConfirmModal(props) {
+ const {
+ isOpen,
+ kind,
+ size,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ hideCancelButton,
+ isSpinning,
+ onConfirm,
+ onCancel
+ } = props;
+
+ return (
+
+
+ {title}
+
+
+ {message}
+
+
+
+ {
+ !hideCancelButton &&
+
+ {cancelLabel}
+
+ }
+
+
+ {confirmLabel}
+
+
+
+
+ );
+}
+
+ConfirmModal.propTypes = {
+ className: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all),
+ size: PropTypes.oneOf(sizes.all),
+ title: PropTypes.string.isRequired,
+ message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ confirmLabel: PropTypes.string,
+ cancelLabel: PropTypes.string,
+ hideCancelButton: PropTypes.bool,
+ isSpinning: PropTypes.bool.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired
+};
+
+ConfirmModal.defaultProps = {
+ kind: kinds.PRIMARY,
+ size: sizes.MEDIUM,
+ confirmLabel: 'OK',
+ cancelLabel: 'Cancel',
+ isSpinning: false
+};
+
+export default ConfirmModal;
diff --git a/frontend/src/Components/Modal/Modal.css b/frontend/src/Components/Modal/Modal.css
new file mode 100644
index 000000000..a9b2a27ae
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.css
@@ -0,0 +1,92 @@
+.modalContainer {
+ position: absolute;
+ top: 0;
+ z-index: 1000;
+ width: 100%;
+ height: 100%;
+}
+
+.modalBackdrop {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: $modalBackdropBackgroundColor;
+ opacity: 1;
+}
+
+.modal {
+ position: relative;
+ display: flex;
+ max-height: 90%;
+ border-radius: 6px;
+ opacity: 1;
+}
+
+.modalOpen {
+ /* Prevent the body from scrolling when the modal is open */
+ overflow: hidden !important;
+}
+
+/*
+ * Sizes
+ */
+
+.small {
+ composes: modal;
+
+ width: 480px;
+}
+
+.medium {
+ composes: modal;
+
+ width: 720px;
+}
+
+.large {
+ composes: modal;
+
+ width: 1080px;
+}
+
+.extraLarge {
+ composes: modal;
+
+ width: 1280px;
+}
+
+@media only screen and (max-width: $breakpointExtraLarge) {
+ .modal.extraLarge {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .modal.large {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .modal.small,
+ .modal.medium {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalContainer {
+ position: fixed;
+ }
+
+ .modal.small,
+ .modal.medium,
+ .modal.large,
+ .modal.extraLarge {
+ max-height: 100%;
+ width: 100%;
+ height: 100% !important;
+ }
+}
diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js
new file mode 100644
index 000000000..a9de82c6d
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.js
@@ -0,0 +1,215 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import classNames from 'classnames';
+import elementClass from 'element-class';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { sizes } from 'Helpers/Props';
+import ErrorBoundary from 'Components/Error/ErrorBoundary';
+import ModalError from './ModalError';
+import styles from './Modal.css';
+
+const openModals = [];
+
+function removeFromOpenModals(id) {
+ const index = openModals.indexOf(id);
+
+ if (index >= 0) {
+ openModals.splice(index, 1);
+ }
+}
+
+class Modal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._node = document.getElementById('modal-root');
+ this._backgroundRef = null;
+ this._modalId = getUniqueElememtId();
+ }
+
+ componentDidMount() {
+ if (this.props.isOpen) {
+ this._openModal();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isOpen
+ } = this.props;
+
+ if (!prevProps.isOpen && isOpen) {
+ this._openModal();
+ } else if (prevProps.isOpen && !isOpen) {
+ this._closeModal();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.props.isOpen) {
+ this._closeModal();
+ }
+ }
+
+ //
+ // Control
+
+ _setBackgroundRef = (ref) => {
+ this._backgroundRef = ref;
+ }
+
+ _openModal() {
+ openModals.push(this._modalId);
+ window.addEventListener('keydown', this.onKeyDown);
+
+ if (openModals.length === 1) {
+ elementClass(document.body).add(styles.modalOpen);
+ }
+ }
+
+ _closeModal() {
+ removeFromOpenModals(this._modalId);
+ window.removeEventListener('keydown', this.onKeyDown);
+
+ if (openModals.length === 0) {
+ elementClass(document.body).remove(styles.modalOpen);
+ }
+ }
+
+ _isBackdropTarget(event) {
+ const targetElement = this._findEventTarget(event);
+
+ if (targetElement) {
+ const backgroundElement = ReactDOM.findDOMNode(this._backgroundRef);
+
+ return backgroundElement.isEqualNode(targetElement);
+ }
+
+ return false;
+ }
+
+ _findEventTarget(event) {
+ const changedTouches = event.changedTouches;
+
+ if (!changedTouches) {
+ return event.target;
+ }
+
+ if (changedTouches.length === 1) {
+ const touch = changedTouches[0];
+
+ return document.elementFromPoint(touch.clientX, touch.clientY);
+ }
+ }
+
+ //
+ // Listeners
+
+ onBackdropBeginPress = (event) => {
+ this._isBackdropPressed = this._isBackdropTarget(event);
+ }
+
+ onBackdropEndPress = (event) => {
+ const {
+ closeOnBackgroundClick,
+ onModalClose
+ } = this.props;
+
+ if (
+ this._isBackdropPressed &&
+ this._isBackdropTarget(event) &&
+ closeOnBackgroundClick
+ ) {
+ onModalClose();
+ }
+
+ this._isBackdropPressed = false;
+ }
+
+ onKeyDown = (event) => {
+ const keyCode = event.keyCode;
+
+ if (keyCode === keyCodes.ESCAPE) {
+ if (openModals.indexOf(this._modalId) === openModals.length - 1) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.props.onModalClose();
+ }
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ backdropClassName,
+ size,
+ children,
+ isOpen,
+ onModalClose
+ } = this.props;
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return ReactDOM.createPortal(
+ ,
+ this._node
+ );
+ }
+}
+
+Modal.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ backdropClassName: PropTypes.string,
+ size: PropTypes.oneOf(sizes.all),
+ children: PropTypes.node,
+ isOpen: PropTypes.bool.isRequired,
+ closeOnBackgroundClick: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+Modal.defaultProps = {
+ className: styles.modal,
+ backdropClassName: styles.modalBackdrop,
+ size: sizes.LARGE,
+ closeOnBackgroundClick: true
+};
+
+export default Modal;
diff --git a/frontend/src/Components/Modal/ModalBody.css b/frontend/src/Components/Modal/ModalBody.css
new file mode 100644
index 000000000..ebeef29de
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalBody.css
@@ -0,0 +1,12 @@
+.modalBody {
+ flex: 1 0 1px;
+ padding: $modalBodyPadding;
+}
+
+.modalScroller {
+ flex-grow: 1;
+}
+
+.innerModalBody {
+ padding: $modalBodyPadding;
+}
diff --git a/frontend/src/Components/Modal/ModalBody.js b/frontend/src/Components/Modal/ModalBody.js
new file mode 100644
index 000000000..a35f2ecf5
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalBody.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './ModalBody.css';
+
+class ModalBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ innerClassName,
+ scrollDirection,
+ children,
+ ...otherProps
+ } = this.props;
+
+ let className = this.props.className;
+ const hasScroller = scrollDirection !== scrollDirections.NONE;
+
+ if (!className) {
+ className = hasScroller ? styles.modalScroller : styles.modalBody;
+ }
+
+ return (
+
+ {
+ hasScroller ?
+
+ {children}
+
:
+ children
+ }
+
+ );
+ }
+
+}
+
+ModalBody.propTypes = {
+ className: PropTypes.string,
+ innerClassName: PropTypes.string,
+ children: PropTypes.node,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL])
+};
+
+ModalBody.defaultProps = {
+ innerClassName: styles.innerModalBody,
+ scrollDirection: scrollDirections.VERTICAL
+};
+
+export default ModalBody;
diff --git a/frontend/src/Components/Modal/ModalContent.css b/frontend/src/Components/Modal/ModalContent.css
new file mode 100644
index 000000000..afd798dfa
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalContent.css
@@ -0,0 +1,23 @@
+.modalContent {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ width: 100%;
+ background-color: $modalBackgroundColor;
+}
+
+.closeButton {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ width: 60px;
+ height: 60px;
+ text-align: center;
+ line-height: 60px;
+
+ &:hover {
+ color: $modalCloseButtonHoverColor;
+ }
+}
diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js
new file mode 100644
index 000000000..655046fe4
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalContent.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './ModalContent.css';
+
+function ModalContent(props) {
+ const {
+ className,
+ children,
+ showCloseButton,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ showCloseButton &&
+
+
+
+ }
+
+ {children}
+
+ );
+}
+
+ModalContent.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node,
+ showCloseButton: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+ModalContent.defaultProps = {
+ className: styles.modalContent,
+ showCloseButton: true
+};
+
+export default ModalContent;
diff --git a/frontend/src/Components/Modal/ModalError.css b/frontend/src/Components/Modal/ModalError.css
new file mode 100644
index 000000000..54dbdbc63
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalError.css
@@ -0,0 +1,15 @@
+.message {
+ composes: message from 'Components/Error/ErrorBoundaryError.css';
+
+ margin: 0;
+ margin-bottom: 30px;
+ font-weight: normal;
+ font-size: 26px;
+}
+
+.details {
+ composes: details from 'Components/Error/ErrorBoundaryError.css';
+
+ margin: 0;
+ margin-top: 20px;
+}
diff --git a/frontend/src/Components/Modal/ModalError.js b/frontend/src/Components/Modal/ModalError.js
new file mode 100644
index 000000000..df99a5b32
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalError.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './ModalError.css';
+
+function ModalError(props) {
+ const {
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ Error
+
+
+
+
+
+
+
+
+ Close
+
+
+ );
+}
+
+ModalError.propTypes = {
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ModalError;
diff --git a/frontend/src/Components/Modal/ModalFooter.css b/frontend/src/Components/Modal/ModalFooter.css
new file mode 100644
index 000000000..3b817d2bf
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalFooter.css
@@ -0,0 +1,23 @@
+.modalFooter {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-shrink: 0;
+ padding: 15px 30px;
+ border-top: 1px solid $borderColor;
+
+ a,
+ button {
+ margin-left: 10px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalFooter {
+ padding: 15px;
+ }
+}
diff --git a/frontend/src/Components/Modal/ModalFooter.js b/frontend/src/Components/Modal/ModalFooter.js
new file mode 100644
index 000000000..0cf8811d3
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalFooter.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './ModalFooter.css';
+
+class ModalFooter extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+ModalFooter.propTypes = {
+ children: PropTypes.node
+};
+
+export default ModalFooter;
diff --git a/frontend/src/Components/Modal/ModalHeader.css b/frontend/src/Components/Modal/ModalHeader.css
new file mode 100644
index 000000000..eab77a9f8
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalHeader.css
@@ -0,0 +1,8 @@
+.modalHeader {
+ @add-mixin truncate;
+
+ flex-shrink: 0;
+ padding: 15px 50px 15px 30px;
+ border-bottom: 1px solid $borderColor;
+ font-size: 18px;
+}
diff --git a/frontend/src/Components/Modal/ModalHeader.js b/frontend/src/Components/Modal/ModalHeader.js
new file mode 100644
index 000000000..52879b57d
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalHeader.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './ModalHeader.css';
+
+class ModalHeader extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+ModalHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default ModalHeader;
diff --git a/frontend/src/Components/MonitorToggleButton.css b/frontend/src/Components/MonitorToggleButton.css
new file mode 100644
index 000000000..794af1e98
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.css
@@ -0,0 +1,11 @@
+.toggleButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ padding: 0;
+ font-size: inherit;
+}
+
+.isDisabled {
+ color: $disabledColor;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js
new file mode 100644
index 000000000..15ac3d7fe
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import styles from './MonitorToggleButton.css';
+
+function getTooltip(monitored, isDisabled) {
+ if (isDisabled) {
+ return 'Cannot toogle monitored state when series is unmonitored';
+ }
+
+ if (monitored) {
+ return 'Monitored, click to unmonitor';
+ }
+
+ return 'Unmonitored, click to monitor';
+}
+
+class MonitorToggleButton extends Component {
+
+ //
+ // Listeners
+
+ onPress = (event) => {
+ const shiftKey = event.nativeEvent.shiftKey;
+
+ this.props.onPress(!this.props.monitored, { shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ monitored,
+ isDisabled,
+ isSaving,
+ size,
+ ...otherProps
+ } = this.props;
+
+ const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
+
+ return (
+
+ );
+ }
+}
+
+MonitorToggleButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ size: PropTypes.number,
+ isDisabled: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+MonitorToggleButton.defaultProps = {
+ className: styles.toggleButton,
+ isDisabled: false,
+ isSaving: false
+};
+
+export default MonitorToggleButton;
diff --git a/frontend/src/Components/NotFound.css b/frontend/src/Components/NotFound.css
new file mode 100644
index 000000000..9aaf1114f
--- /dev/null
+++ b/frontend/src/Components/NotFound.css
@@ -0,0 +1,14 @@
+.container {
+ text-align: center;
+}
+
+.message {
+ margin: 50px 0;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.image {
+ height: 350px;
+}
diff --git a/frontend/src/Components/NotFound.js b/frontend/src/Components/NotFound.js
new file mode 100644
index 000000000..cdd6eda2c
--- /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..018e98ca1
--- /dev/null
+++ b/frontend/src/Components/Page/ErrorPage.js
@@ -0,0 +1,56 @@
+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,
+ moviesError,
+ customFiltersError,
+ tagsError,
+ qualityProfilesError,
+ uiSettingsError
+ } = props;
+
+ let errorMessage = 'Failed to load Radarr';
+
+ if (!isLocalStorageSupported) {
+ errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
+ } else if (moviesError) {
+ errorMessage = getErrorMessage(moviesError, 'Failed to load movie from API');
+ } else if (customFiltersError) {
+ errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API');
+ } else if (tagsError) {
+ errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API');
+ } else if (qualityProfilesError) {
+ errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API');
+ } else if (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,
+ moviesError: PropTypes.object,
+ customFiltersError: 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/MovieSearchInput.css b/frontend/src/Components/Page/Header/MovieSearchInput.css
new file mode 100644
index 000000000..10eefae1e
--- /dev/null
+++ b/frontend/src/Components/Page/Header/MovieSearchInput.css
@@ -0,0 +1,96 @@
+.wrapper {
+ display: flex;
+ align-items: center;
+}
+
+.input {
+ margin-left: 8px;
+ width: 200px;
+ border: none;
+ border-bottom: solid 1px $white;
+ border-radius: 0;
+ background-color: transparent;
+ box-shadow: none;
+ color: $white;
+ transition: border 0.3s ease-out;
+
+ &::placeholder {
+ color: $white;
+ transition: color 0.3s ease-out;
+ }
+
+ &:focus {
+ outline: 0;
+ border-bottom-color: transparent;
+
+ &::placeholder {
+ color: transparent;
+ }
+ }
+}
+
+.container {
+ position: relative;
+ flex-grow: 1;
+}
+
+.movieContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.containerOpen {
+ .movieContainer {
+ 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/MovieSearchInput.js b/frontend/src/Components/Page/Header/MovieSearchInput.js
new file mode 100644
index 000000000..fa49f0716
--- /dev/null
+++ b/frontend/src/Components/Page/Header/MovieSearchInput.js
@@ -0,0 +1,264 @@
+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 MovieSearchResult from './MovieSearchResult';
+import styles from './MovieSearchInput.css';
+
+const ADD_NEW_TYPE = 'addNew';
+
+class MovieSearchInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._autosuggest = null;
+
+ this.state = {
+ value: '',
+ suggestions: []
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.MOVIE_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 (
+
+ );
+ }
+
+ goToMovie(movie) {
+ this.setState({ value: '' });
+ this.props.onGoToMovie(movie.titleSlug);
+ }
+
+ reset() {
+ this.setState({
+ value: '',
+ suggestions: []
+ });
+ }
+
+ //
+ // Listeners
+
+ onChange = (event, { newValue, method }) => {
+ if (method === 'up' || method === 'down') {
+ return;
+ }
+
+ this.setState({ value: newValue });
+ }
+
+ onKeyDown = (event) => {
+ if (event.key !== 'Tab' && event.key !== 'Enter') {
+ return;
+ }
+
+ const {
+ suggestions,
+ value
+ } = this.state;
+
+ const {
+ highlightedSectionIndex,
+ highlightedSuggestionIndex
+ } = this._autosuggest.state;
+
+ if (!suggestions.length || highlightedSectionIndex) {
+ this.props.onGoToAddNewMovie(value);
+ this._autosuggest.input.blur();
+ this.reset();
+
+ return;
+ }
+
+ // If an suggestion is not selected go to the first series,
+ // otherwise go to the selected series.
+
+ if (highlightedSuggestionIndex == null) {
+ this.goToMovie(suggestions[0]);
+ } else {
+ this.goToMovie(suggestions[highlightedSuggestionIndex]);
+ }
+
+ this._autosuggest.input.blur();
+ this.reset();
+ }
+
+ onBlur = () => {
+ this.reset();
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const lowerCaseValue = jdu.replace(value).toLowerCase();
+
+ const suggestions = this.props.movie.filter((movie) => {
+ // Check the title first and if there isn't a match fallback to
+ // the alternate titles and finally the tags.
+
+ if (value.length === 1) {
+ return (
+ movie.cleanTitle.startsWith(lowerCaseValue) ||
+ movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.startsWith(lowerCaseValue)) ||
+ movie.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue))
+ );
+ }
+
+ return (
+ movie.cleanTitle.contains(lowerCaseValue) ||
+ movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) ||
+ movie.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue))
+ );
+ });
+
+ this.setState({ suggestions });
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.setState({
+ suggestions: []
+ });
+ }
+
+ onSuggestionSelected = (event, { suggestion }) => {
+ if (suggestion.type === ADD_NEW_TYPE) {
+ this.props.onGoToAddNewMovie(this.state.value);
+ } else {
+ this.goToMovie(suggestion);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const suggestionGroups = [];
+
+ if (suggestions.length) {
+ suggestionGroups.push({
+ title: 'Existing Movie',
+ suggestions
+ });
+ }
+
+ suggestionGroups.push({
+ title: 'Add New Movie',
+ 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.movieContainer,
+ suggestionsList: styles.list,
+ suggestion: styles.listItem,
+ suggestionHighlighted: styles.highlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+MovieSearchInput.propTypes = {
+ movie: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onGoToMovie: PropTypes.func.isRequired,
+ onGoToAddNewMovie: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(MovieSearchInput);
diff --git a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js
new file mode 100644
index 000000000..10f3f52d7
--- /dev/null
+++ b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js
@@ -0,0 +1,98 @@
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import { createSelector } from 'reselect';
+import jdu from 'jdu';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import MovieSearchInput from './MovieSearchInput';
+
+function createCleanTagsSelector() {
+ return createSelector(
+ createTagsSelector(),
+ (tags) => {
+ return tags.map((tag) => {
+ const {
+ id,
+ label
+ } = tag;
+
+ return {
+ id,
+ label,
+ cleanLabel: jdu.replace(label).toLowerCase()
+ };
+ });
+ }
+ );
+}
+
+function createCleanMovieSelector() {
+ return createSelector(
+ createAllMoviesSelector(),
+ createCleanTagsSelector(),
+ (allMovies, allTags) => {
+ return allMovies.map((movie) => {
+ const {
+ title,
+ titleSlug,
+ sortTitle,
+ images,
+ alternateTitles = [],
+ tags = []
+ } = movie;
+
+ return {
+ title,
+ titleSlug,
+ sortTitle,
+ images,
+ cleanTitle: jdu.replace(title).toLowerCase(),
+ alternateTitles: alternateTitles.map((alternateTitle) => {
+ return {
+ title: alternateTitle.title,
+ sortTitle: alternateTitle.sortTitle,
+ cleanTitle: jdu.replace(alternateTitle.title).toLowerCase()
+ };
+ }),
+ tags: tags.map((id) => {
+ return allTags.find((tag) => tag.id === id);
+ })
+ };
+ }).sort((a, b) => {
+ if (a.sortTitle < b.sortTitle) {
+ return -1;
+ }
+ if (a.sortTitle > b.sortTitle) {
+ return 1;
+ }
+
+ return 0;
+ });
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createCleanMovieSelector(),
+ (movie) => {
+ return {
+ movie
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onGoToMovie(titleSlug) {
+ dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`));
+ },
+
+ onGoToAddNewMovie(query) {
+ dispatch(push(`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchInput);
diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.css b/frontend/src/Components/Page/Header/MovieSearchResult.css
new file mode 100644
index 000000000..29edc382b
--- /dev/null
+++ b/frontend/src/Components/Page/Header/MovieSearchResult.css
@@ -0,0 +1,38 @@
+.result {
+ display: flex;
+ padding: 3px;
+ cursor: pointer;
+}
+
+.poster {
+ width: 35px;
+ height: 50px;
+}
+
+.titles {
+ flex: 1 1 1px;
+}
+
+.title {
+ flex: 1 1 1px;
+ margin-left: 5px;
+}
+
+.alternateTitle {
+ composes: title;
+
+ color: $disabledColor;
+ font-size: $smallFontSize;
+}
+
+.tagContainer {
+ composes: title;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .titles,
+ .title,
+ .alternateTitle {
+ @add-mixin truncate;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.js b/frontend/src/Components/Page/Header/MovieSearchResult.js
new file mode 100644
index 000000000..83211a766
--- /dev/null
+++ b/frontend/src/Components/Page/Header/MovieSearchResult.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import MoviePoster from 'Movie/MoviePoster';
+import styles from './MovieSearchResult.css';
+
+function findMatchingAlternateTitle(alternateTitles, cleanQuery) {
+ return alternateTitles.find((alternateTitle) => {
+ return alternateTitle.cleanTitle.contains(cleanQuery);
+ });
+}
+
+function getMatchingTag(tags, cleanQuery) {
+ return tags.find((tag) => {
+ return tag.cleanLabel.contains(cleanQuery);
+ });
+}
+
+function MovieSearchResult(props) {
+ const {
+ cleanQuery,
+ title,
+ cleanTitle,
+ images,
+ alternateTitles,
+ tags
+ } = props;
+
+ const titleContains = cleanTitle.contains(cleanQuery);
+ let alternateTitle = null;
+ let tag = null;
+
+ if (!titleContains) {
+ alternateTitle = findMatchingAlternateTitle(alternateTitles, cleanQuery);
+ }
+
+ if (!titleContains && !alternateTitle) {
+ tag = getMatchingTag(tags, cleanQuery);
+ }
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {
+ !!alternateTitle &&
+
+ {alternateTitle.title}
+
+ }
+
+ {
+ !!tag &&
+
+
+ {tag.label}
+
+
+ }
+
+
+ );
+}
+
+MovieSearchResult.propTypes = {
+ cleanQuery: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ cleanTitle: PropTypes.string.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default MovieSearchResult;
diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css
new file mode 100644
index 000000000..a3208ca66
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.css
@@ -0,0 +1,65 @@
+.header {
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ height: $headerHeight;
+ background-color: #464b51;
+ color: $white;
+}
+
+.logoContainer {
+ display: flex;
+ justify-content: center;
+ flex: 0 0 $sidebarWidth;
+}
+
+.logoFull {
+ width: 144px;
+ height: 48px;
+}
+
+.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..1847d937f
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.js
@@ -0,0 +1,100 @@
+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 MovieSearchInputConnector from './MovieSearchInputConnector';
+import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
+import KeyboardShortcutsModal from './KeyboardShortcutsModal';
+import styles from './PageHeader.css';
+
+class PageHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props);
+
+ this.state = {
+ isKeyboardShortcutsModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
+ }
+
+ //
+ // Control
+
+ onOpenKeyboardShortcutsModal = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: true });
+ }
+
+ //
+ // Listeners
+
+ onKeyboardShortcutsModalClose = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onSidebarToggle,
+ isSmallScreen
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+PageHeader.propTypes = {
+ onSidebarToggle: PropTypes.func.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(PageHeader);
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
new file mode 100644
index 000000000..e27ad883e
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
@@ -0,0 +1,21 @@
+.menuButton {
+ margin-right: 15px;
+ width: 30px;
+ height: 60px;
+ text-align: center;
+
+ &:hover {
+ color: $themeDarkColor;
+ }
+}
+
+.itemIcon {
+ margin-right: 8px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .menuButton {
+ margin-right: 5px;
+ }
+}
+
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
new file mode 100644
index 000000000..8ad7c8409
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
+import styles from './PageHeaderActionsMenu.css';
+
+function PageHeaderActionsMenu(props) {
+ const {
+ formsAuth,
+ onKeyboardShortcutsPress,
+ onRestartPress,
+ onShutdownPress
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ Keyboard Shortcuts
+
+
+
+
+
+
+ Restart
+
+
+
+
+ Shutdown
+
+
+ {
+ formsAuth &&
+
+ }
+
+ {
+ formsAuth &&
+
+
+ Logout
+
+ }
+
+
+
+ );
+}
+
+PageHeaderActionsMenu.propTypes = {
+ formsAuth: PropTypes.bool.isRequired,
+ onKeyboardShortcutsPress: PropTypes.func.isRequired,
+ onRestartPress: PropTypes.func.isRequired,
+ onShutdownPress: PropTypes.func.isRequired
+};
+
+export default PageHeaderActionsMenu;
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js
new file mode 100644
index 000000000..66d131521
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { restart, shutdown } from 'Store/Actions/systemActions';
+import PageHeaderActionsMenu from './PageHeaderActionsMenu';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return {
+ formsAuth: status.item.authentication === 'forms'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ restart,
+ shutdown
+};
+
+class PageHeaderActionsMenuConnector extends Component {
+
+ //
+ // Listeners
+
+ onRestartPress = () => {
+ this.props.restart();
+ }
+
+ onShutdownPress = () => {
+ this.props.shutdown();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+PageHeaderActionsMenuConnector.propTypes = {
+ restart: PropTypes.func.isRequired,
+ shutdown: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);
diff --git a/frontend/src/Components/Page/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..2bb59c532
--- /dev/null
+++ b/frontend/src/Components/Page/Page.js
@@ -0,0 +1,136 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import locationShape from 'Helpers/Props/Shapes/locationShape';
+import SignalRConnector from 'Components/SignalRConnector';
+import ColorImpairedContext from 'App/ColorImpairedContext';
+import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
+import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
+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,
+ enableColorImpairedMode,
+ 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,
+ enableColorImpairedMode: 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..a32bbcef7
--- /dev/null
+++ b/frontend/src/Components/Page/PageConnector.js
@@ -0,0 +1,197 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
+import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
+import { fetchMovies } from 'Store/Actions/movieActions';
+import { fetchTags } from 'Store/Actions/tagActions';
+import { fetchQualityProfiles, 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 = 'radarrTest';
+
+ try {
+ localStorage.setItem(key, key);
+ localStorage.removeItem(key);
+
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movies,
+ (state) => state.customFilters,
+ (state) => state.tags,
+ (state) => state.settings.ui,
+ (state) => state.settings.qualityProfiles,
+ (state) => state.app,
+ createDimensionsSelector(),
+ (
+ movies,
+ customFilters,
+ tags,
+ uiSettings,
+ qualityProfiles,
+ app,
+ dimensions
+ ) => {
+ const isPopulated = (
+ movies.isPopulated &&
+ customFilters.isPopulated &&
+ tags.isPopulated &&
+ qualityProfiles.isPopulated &&
+ uiSettings.isPopulated
+ );
+
+ const hasError = !!(
+ movies.error ||
+ customFilters.error ||
+ tags.error ||
+ qualityProfiles.error ||
+ uiSettings.error
+ );
+
+ return {
+ isPopulated,
+ hasError,
+ moviesError: movies.error,
+ customFiltersError: tags.error,
+ tagsError: tags.error,
+ qualityProfilesError: qualityProfiles.error,
+ uiSettingsError: uiSettings.error,
+ isSmallScreen: dimensions.isSmallScreen,
+ isSidebarVisible: app.isSidebarVisible,
+ enableColorImpairedMode: uiSettings.item.enableColorImpairedMode,
+ version: app.version,
+ isUpdated: app.isUpdated,
+ isDisconnected: app.isDisconnected
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchMovies() {
+ dispatch(fetchMovies());
+ },
+ dispatchFetchCustomFilters() {
+ dispatch(fetchCustomFilters());
+ },
+ dispatchFetchTags() {
+ dispatch(fetchTags());
+ },
+ dispatchFetchQualityProfiles() {
+ dispatch(fetchQualityProfiles());
+ },
+ 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.dispatchFetchMovies();
+ this.props.dispatchFetchCustomFilters();
+ this.props.dispatchFetchTags();
+ this.props.dispatchFetchQualityProfiles();
+ this.props.dispatchFetchUISettings();
+ this.props.dispatchFetchStatus();
+ }
+ }
+
+ //
+ // Listeners
+
+ onSidebarToggle = () => {
+ this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isPopulated,
+ hasError,
+ dispatchFetchMovies,
+ dispatchFetchTags,
+ dispatchFetchQualityProfiles,
+ 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,
+ dispatchFetchMovies: PropTypes.func.isRequired,
+ dispatchFetchCustomFilters: PropTypes.func.isRequired,
+ dispatchFetchTags: PropTypes.func.isRequired,
+ dispatchFetchQualityProfiles: 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..62d5b5d13
--- /dev/null
+++ b/frontend/src/Components/Page/PageContent.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import ErrorBoundary from 'Components/Error/ErrorBoundary';
+import PageContentError from './PageContentError';
+import styles from './PageContent.css';
+
+function PageContent(props) {
+ const {
+ className,
+ title,
+ children
+ } = props;
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+PageContent.propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageContent.defaultProps = {
+ className: styles.content
+};
+
+export default PageContent;
diff --git a/frontend/src/Components/Page/PageContentBody.css b/frontend/src/Components/Page/PageContentBody.css
new file mode 100644
index 000000000..8b41754dd
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBody.css
@@ -0,0 +1,19 @@
+.contentBody {
+ /* 1px for flex-basis so the div grows correctly in Edge/Firefox */
+ flex: 1 0 1px;
+}
+
+.innerContentBody {
+ padding: $pageContentBodyPadding;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentBody {
+ flex-basis: auto;
+ overflow-y: hidden !important;
+ }
+
+ .innerContentBody {
+ padding: $pageContentBodyPaddingSmallScreen;
+ }
+}
diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js
new file mode 100644
index 000000000..81bd9b29b
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBody.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import OverlayScroller from 'Components/Scroller/OverlayScroller';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './PageContentBody.css';
+
+class PageContentBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ innerClassName,
+ isSmallScreen,
+ children,
+ dispatch,
+ ...otherProps
+ } = this.props;
+
+ const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+PageContentBody.propTypes = {
+ className: PropTypes.string,
+ innerClassName: PropTypes.string,
+ isSmallScreen: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ dispatch: PropTypes.func
+};
+
+PageContentBody.defaultProps = {
+ className: styles.contentBody,
+ innerClassName: styles.innerContentBody
+};
+
+export default PageContentBody;
diff --git a/frontend/src/Components/Page/PageContentBodyConnector.js b/frontend/src/Components/Page/PageContentBodyConnector.js
new file mode 100644
index 000000000..b5cdfbb21
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBodyConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import PageContentBody from './PageContentBody';
+
+function createMapStateToProps() {
+ return createSelector(
+ createDimensionsSelector(),
+ (dimensions) => {
+ return {
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(PageContentBody);
diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css
new file mode 100644
index 000000000..7b1f7a6db
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentError.css
@@ -0,0 +1,3 @@
+.content {
+ composes: content from './PageContent.css';
+}
diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.js
new file mode 100644
index 000000000..5ae41a936
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentError.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
+import PageContentBodyConnector from './PageContentBodyConnector';
+import styles from './PageContentError.css';
+
+function PageContentError(props) {
+ return (
+
+ );
+}
+
+export default PageContentError;
diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css
new file mode 100644
index 000000000..74bdb3811
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentFooter.css
@@ -0,0 +1,26 @@
+.contentFooter {
+ display: flex;
+ flex: 0 0 auto;
+ padding: 20px;
+ background-color: #f1f1f1;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentFooter {
+ display: block;
+
+ div {
+ margin-top: 10px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .contentFooter {
+ flex-wrap: wrap;
+ }
+}
diff --git a/frontend/src/Components/Page/PageContentFooter.js b/frontend/src/Components/Page/PageContentFooter.js
new file mode 100644
index 000000000..1f6e2d21a
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentFooter.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './PageContentFooter.css';
+
+class PageContentFooter extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+PageContentFooter.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageContentFooter.defaultProps = {
+ className: styles.contentFooter
+};
+
+export default PageContentFooter;
diff --git a/frontend/src/Components/Page/PageJumpBar.css b/frontend/src/Components/Page/PageJumpBar.css
new file mode 100644
index 000000000..9a116fb54
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBar.css
@@ -0,0 +1,22 @@
+.jumpBar {
+ display: flex;
+ align-content: stretch;
+ align-items: stretch;
+ align-self: stretch;
+ justify-content: center;
+ flex: 0 0 30px;
+}
+
+.jumpBarItems {
+ display: flex;
+ justify-content: space-around;
+ flex: 0 0 100%;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .jumpBar {
+ display: none;
+ }
+}
diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js
new file mode 100644
index 000000000..f555d5426
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBar.js
@@ -0,0 +1,140 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import dimensions from 'Styles/Variables/dimensions';
+import Measure from 'Components/Measure';
+import PageJumpBarItem from './PageJumpBarItem';
+import styles from './PageJumpBar.css';
+
+const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
+
+class PageJumpBar extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ height: 0,
+ visibleItems: props.items
+ };
+ }
+
+ componentDidMount() {
+ this.computeVisibleItems();
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ nextProps.items !== this.props.items ||
+ nextState.height !== this.state.height
+ );
+ }
+
+ 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..a28a28019
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import styles from './Message.css';
+
+function getIconName(name) {
+ switch (name) {
+ case 'ApplicationUpdate':
+ return icons.RESTART;
+ case 'Backup':
+ return icons.BACKUP;
+ case 'CheckHealth':
+ return icons.HEALTH;
+ case 'EpisodeSearch':
+ return icons.SEARCH;
+ case 'Housekeeping':
+ return icons.HOUSEKEEPING;
+ case 'RefreshMovie':
+ return icons.REFRESH;
+ case 'RssSync':
+ return icons.RSS;
+ case 'SeasonSearch':
+ return icons.SEARCH;
+ case 'MovieSearch':
+ return icons.SEARCH;
+ case 'UpdateSceneMapping':
+ return icons.REFRESH;
+ default:
+ return icons.SPINNER;
+ }
+}
+
+function Message(props) {
+ const {
+ name,
+ message,
+ type
+ } = props;
+
+ return (
+
+
+
+
+
+
+ {message}
+
+
+ );
+}
+
+Message.propTypes = {
+ name: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired
+};
+
+export default Message;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js
new file mode 100644
index 000000000..06c545c27
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { hideMessage } from 'Store/Actions/appActions';
+import Message from './Message';
+
+const mapDispatchToProps = {
+ hideMessage
+};
+
+class MessageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._hideTimeoutId = null;
+ this.scheduleHideMessage(props.hideAfter);
+ }
+
+ componentDidUpdate() {
+ this.scheduleHideMessage(this.props.hideAfter);
+ }
+
+ //
+ // Control
+
+ scheduleHideMessage = (hideAfter) => {
+ if (this._hideTimeoutId) {
+ clearTimeout(this._hideTimeoutId);
+ }
+
+ if (hideAfter) {
+ this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000);
+ }
+ }
+
+ hideMessage = () => {
+ this.props.hideMessage({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MessageConnector.propTypes = {
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ hideAfter: PropTypes.number.isRequired,
+ hideMessage: PropTypes.func.isRequired
+};
+
+MessageConnector.defaultProps = {
+ // Hide messages after 60 seconds if there is no activity
+ // hideAfter: 60
+};
+
+export default connect(undefined, mapDispatchToProps)(MessageConnector);
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.css b/frontend/src/Components/Page/Sidebar/Messages/Messages.css
new file mode 100644
index 000000000..ef01ad02c
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.css
@@ -0,0 +1,11 @@
+.messages {
+ margin-top: auto;
+ margin-bottom: 20px;
+ padding-top: 20px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .messages {
+ margin-bottom: 0;
+ }
+}
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.js b/frontend/src/Components/Page/Sidebar/Messages/Messages.js
new file mode 100644
index 000000000..ec8876f6e
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import MessageConnector from './MessageConnector';
+import styles from './Messages.css';
+
+function Messages({ messages }) {
+ return (
+
+ {
+ messages.map((message) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+Messages.propTypes = {
+ messages: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Messages;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js
new file mode 100644
index 000000000..5d20d9194
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import Messages from './Messages';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.messages.items,
+ (messages) => {
+ return {
+ messages: messages.slice().reverse()
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Messages);
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.css b/frontend/src/Components/Page/Sidebar/PageSidebar.css
new file mode 100644
index 000000000..fdbd80320
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.css
@@ -0,0 +1,34 @@
+.sidebarContainer {
+ flex: 0 0 $sidebarWidth;
+ overflow: hidden;
+ width: $sidebarWidth;
+ background-color: $sidebarBackgroundColor;
+ transition: transform 300ms ease-in-out;
+ transform: translateX(0);
+}
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: $sidebarBackgroundColor;
+ color: $white;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .sidebarContainer {
+ position: fixed;
+ top: 0;
+ z-index: 2;
+ height: 100vh;
+ }
+
+ .sidebar {
+ position: fixed;
+ z-index: 2;
+ overflow-y: auto;
+ width: 100%;
+ height: 100%;
+ }
+}
+
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js
new file mode 100644
index 000000000..8d295be28
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js
@@ -0,0 +1,513 @@
+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: 'Movies',
+ to: '/',
+ alias: '/movies',
+ children: [
+ {
+ title: 'Add New',
+ to: '/add/new'
+ },
+ {
+ title: 'Import',
+ to: '/add/import'
+ },
+ {
+ title: 'Discover',
+ to: '/add/discover'
+ },
+ {
+ title: 'Lists',
+ to: '/add/lists'
+ }
+ ]
+ },
+
+ {
+ 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.SETTINGS,
+ title: 'Settings',
+ to: '/settings',
+ children: [
+ {
+ title: 'Media Management',
+ to: '/settings/mediamanagement'
+ },
+ {
+ title: 'Profiles',
+ to: '/settings/profiles'
+ },
+ {
+ title: 'Quality',
+ to: '/settings/quality'
+ },
+ {
+ title: 'Indexers',
+ to: '/settings/indexers'
+ },
+ {
+ title: 'Download Clients',
+ to: '/settings/downloadclients'
+ },
+ {
+ title: 'Lists',
+ to: '/settings/netimports'
+ },
+ {
+ title: 'Connect',
+ to: '/settings/connect'
+ },
+ {
+ title: 'Metadata',
+ to: '/settings/metadata'
+ },
+ {
+ title: 'Tags',
+ to: '/settings/tags'
+ },
+ {
+ title: 'General',
+ to: '/settings/general'
+ },
+ {
+ title: 'UI',
+ to: '/settings/ui'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.SYSTEM,
+ title: 'System',
+ to: '/system/status',
+ children: [
+ {
+ title: 'Status',
+ to: '/system/status',
+ statusComponent: HealthStatusConnector
+ },
+ {
+ title: 'Tasks',
+ to: '/system/tasks'
+ },
+ {
+ title: 'Backup',
+ to: '/system/backup'
+ },
+ {
+ title: 'Updates',
+ to: '/system/updates'
+ },
+ {
+ title: 'Events',
+ to: '/system/events'
+ },
+ {
+ title: 'Log Files',
+ to: '/system/logs/files'
+ }
+ ]
+ }
+];
+
+function getActiveParent(pathname) {
+ let activeParent = links[0].to;
+
+ links.forEach((link) => {
+ if (link.to && link.to === pathname) {
+ activeParent = link.to;
+
+ return false;
+ }
+
+ const children = link.children;
+
+ if (children) {
+ children.forEach((childLink) => {
+ if (pathname.startsWith(childLink.to)) {
+ activeParent = link.to;
+
+ return false;
+ }
+ });
+ }
+
+ if (
+ (link.to !== '/' && pathname.startsWith(link.to)) ||
+ (link.alias && pathname.startsWith(link.alias))
+ ) {
+ activeParent = link.to;
+
+ return false;
+ }
+ });
+
+ return activeParent;
+}
+
+function hasActiveChildLink(link, pathname) {
+ const children = link.children;
+
+ if (!children || !children.length) {
+ return false;
+ }
+
+ return _.some(children, (child) => {
+ return child.to === pathname;
+ });
+}
+
+function getPositioning() {
+ const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY;
+ const top = Math.max(HEADER_HEIGHT - windowScroll, 0);
+ const height = window.innerHeight - top;
+
+ return {
+ top: `${top}px`,
+ height: `${height}px`
+ };
+}
+
+class PageSidebar extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._touchStartX = null;
+ this._touchStartY = null;
+ this._sidebarRef = null;
+
+ this.state = {
+ top: dimensions.headerHeight,
+ height: `${window.innerHeight - HEADER_HEIGHT}px`,
+ transition: null,
+ transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
+ };
+ }
+
+ componentDidMount() {
+ if (this.props.isSmallScreen) {
+ window.addEventListener('click', this.onWindowClick, { capture: true });
+ window.addEventListener('scroll', this.onWindowScroll);
+ window.addEventListener('touchstart', this.onTouchStart);
+ window.addEventListener('touchmove', this.onTouchMove);
+ window.addEventListener('touchend', this.onTouchEnd);
+ window.addEventListener('touchcancel', this.onTouchCancel);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSidebarVisible
+ } = this.props;
+
+ const transform = this.state.transform;
+
+ if (prevProps.isSidebarVisible !== isSidebarVisible) {
+ this._setSidebarTransform(isSidebarVisible);
+ } else if (transform === 0 && !isSidebarVisible) {
+ this.props.onSidebarVisibleChange(true);
+ } else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) {
+ this.props.onSidebarVisibleChange(false);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.props.isSmallScreen) {
+ window.removeEventListener('click', this.onWindowClick, { capture: true });
+ window.removeEventListener('scroll', this.onWindowScroll);
+ window.removeEventListener('touchstart', this.onTouchStart);
+ window.removeEventListener('touchmove', this.onTouchMove);
+ window.removeEventListener('touchend', this.onTouchEnd);
+ window.removeEventListener('touchcancel', this.onTouchCancel);
+ }
+ }
+
+ //
+ // Control
+
+ _setSidebarRef = (ref) => {
+ this._sidebarRef = ref;
+ }
+
+ _setSidebarTransform(isSidebarVisible, transition, callback) {
+ this.setState({
+ transition,
+ transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
+ }, callback);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const sidebar = ReactDOM.findDOMNode(this._sidebarRef);
+ const toggleButton = document.getElementById('sidebar-toggle-button');
+
+ if (!sidebar) {
+ return;
+ }
+
+ if (
+ !sidebar.contains(event.target) &&
+ !toggleButton.contains(event.target) &&
+ this.props.isSidebarVisible
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.props.onSidebarVisibleChange(false);
+ }
+ }
+
+ onWindowScroll = () => {
+ this.setState(getPositioning());
+ }
+
+ onTouchStart = (event) => {
+ const touches = event.touches;
+ const touchStartX = touches[0].pageX;
+ const touchStartY = touches[0].pageY;
+ const isSidebarVisible = this.props.isSidebarVisible;
+
+ if (touches.length !== 1) {
+ return;
+ }
+
+ if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) {
+ return;
+ } else if (!isSidebarVisible && touchStartX > 40) {
+ return;
+ }
+
+ this._touchStartX = touchStartX;
+ this._touchStartY = touchStartY;
+ }
+
+ onTouchMove = (event) => {
+ const touches = event.touches;
+ const currentTouchX = touches[0].pageX;
+ // const currentTouchY = touches[0].pageY;
+ // const isSidebarVisible = this.props.isSidebarVisible;
+
+ if (!this._touchStartX) {
+ return;
+ }
+
+ // This is a bit funky when trying to close and you scroll
+ // vertical too much by mistake, commenting out for now.
+ // TODO: Evaluate if this should be nuked
+
+ // if (Math.abs(this._touchStartY - currentTouchY) > 40) {
+ // const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1;
+
+ // this.setState({
+ // transition: 'none',
+ // transform
+ // });
+
+ // return;
+ // }
+
+ if (Math.abs(this._touchStartX - currentTouchX) < 40) {
+ return;
+ }
+
+ const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0);
+
+ this.setState({
+ transition: 'none',
+ transform
+ });
+ }
+
+ onTouchEnd = (event) => {
+ const touches = event.changedTouches;
+ const currentTouch = touches[0].pageX;
+
+ if (!this._touchStartX) {
+ return;
+ }
+
+ if (currentTouch > this._touchStartX && currentTouch > 50) {
+ this._setSidebarTransform(true, 'none');
+ } else if (currentTouch < this._touchStartX && currentTouch < 80) {
+ this._setSidebarTransform(false, 'transform 50ms ease-in-out');
+ } else {
+ this._setSidebarTransform(this.props.isSidebarVisible);
+ }
+
+ this._touchStartX = null;
+ this._touchStartY = null;
+ }
+
+ onTouchCancel = (event) => {
+ this._touchStartX = null;
+ this._touchStartY = null;
+ }
+
+ onItemPress = () => {
+ this.props.onSidebarVisibleChange(false);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ location,
+ isSmallScreen
+ } = this.props;
+
+ const {
+ top,
+ height,
+ transition,
+ transform
+ } = this.state;
+
+ const urlBase = window.Radarr.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..dac40927f
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css
@@ -0,0 +1,50 @@
+.item {
+ border-left: 3px solid transparent;
+ color: $sidebarColor;
+ transition: border-left 0.3s ease-in-out;
+}
+
+.isActiveItem {
+ border-left: 3px solid $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;
+ margin-right: 7px;
+ width: 18px;
+ text-align: center;
+}
+
+.noIcon {
+ margin-left: 25px;
+}
+
+.status {
+ float: right;
+}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js
new file mode 100644
index 000000000..d494150c0
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { map } from 'Helpers/elementChildren';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './PageSidebarItem.css';
+
+class PageSidebarItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ isChildItem,
+ isParentItem,
+ onPress
+ } = this.props;
+
+ if (isChildItem || !isParentItem) {
+ onPress();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ iconName,
+ title,
+ to,
+ isActive,
+ isActiveParent,
+ isChildItem,
+ statusComponent: StatusComponent,
+ children
+ } = this.props;
+
+ return (
+
+
+ {
+ !!iconName &&
+
+
+
+ }
+
+
+ {title}
+
+
+ {
+ !!StatusComponent &&
+
+
+
+ }
+
+
+ {
+ children &&
+ map(children, (child) => {
+ return React.cloneElement(child, { isChildItem: true });
+ })
+ }
+
+ );
+ }
+}
+
+PageSidebarItem.propTypes = {
+ iconName: PropTypes.object,
+ title: PropTypes.string.isRequired,
+ to: PropTypes.string.isRequired,
+ isActive: PropTypes.bool,
+ isActiveParent: PropTypes.bool,
+ isParentItem: PropTypes.bool.isRequired,
+ isChildItem: PropTypes.bool.isRequired,
+ statusComponent: PropTypes.func,
+ children: PropTypes.node,
+ onPress: PropTypes.func
+};
+
+PageSidebarItem.defaultProps = {
+ isChildItem: false
+};
+
+export default PageSidebarItem;
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css
new file mode 100644
index 000000000..4dd0cc678
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css
@@ -0,0 +1,3 @@
+.status {
+ composes: label from 'Components/Label.css';
+}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js
new file mode 100644
index 000000000..c1ea615ed
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function PageSidebarStatus({ count, errors, warnings }) {
+ if (!count) {
+ return null;
+ }
+
+ let kind = kinds.INFO;
+
+ if (errors) {
+ kind = kinds.DANGER;
+ } else if (warnings) {
+ kind = kinds.WARNING;
+ }
+
+ return (
+
+ {count}
+
+ );
+}
+
+PageSidebarStatus.propTypes = {
+ count: PropTypes.number,
+ errors: PropTypes.bool,
+ warnings: PropTypes.bool
+};
+
+export default PageSidebarStatus;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.css b/frontend/src/Components/Page/Toolbar/PageToolbar.css
new file mode 100644
index 000000000..e040bc884
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbar.css
@@ -0,0 +1,16 @@
+.toolbar {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 auto;
+ padding: 0 20px;
+ height: $toolbarHeight;
+ background-color: $toolbarBackgroundColor;
+ color: $toolbarColor;
+ line-height: 60px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .toolbar {
+ padding: 0 10px;
+ }
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.js b/frontend/src/Components/Page/Toolbar/PageToolbar.js
new file mode 100644
index 000000000..728f1b0d9
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbar.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './PageToolbar.css';
+
+class PageToolbar extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+PageToolbar.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageToolbar.defaultProps = {
+ className: styles.toolbar
+};
+
+export default PageToolbar;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
new file mode 100644
index 000000000..11944c1e9
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
@@ -0,0 +1,32 @@
+.toolbarButton {
+ composes: link from 'Components/Link/Link.css';
+
+ width: $toolbarButtonWidth;
+ text-align: center;
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+
+ &.isDisabled {
+ color: $disabledColor;
+ }
+}
+
+.isDisabled {
+ color: $disabledColor;
+}
+
+.labelContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 16px;
+}
+
+.label {
+ padding: 0 3px;
+ color: $toolbarLabelColor;
+ font-size: $extraSmallFontSize;
+ line-height: calc($extraSmallFontSize + 1px);
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js
new file mode 100644
index 000000000..381046bf5
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './PageToolbarButton.css';
+
+function PageToolbarButton(props) {
+ const {
+ label,
+ iconName,
+ spinningName,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+}
+
+PageToolbarButton.propTypes = {
+ label: PropTypes.string.isRequired,
+ iconName: PropTypes.object.isRequired,
+ spinningName: PropTypes.object,
+ isSpinning: PropTypes.bool,
+ isDisabled: PropTypes.bool
+};
+
+PageToolbarButton.defaultProps = {
+ spinningName: icons.SPINNER,
+ isDisabled: false,
+ isSpinning: false
+};
+
+export default PageToolbarButton;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css
new file mode 100644
index 000000000..638636ffb
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css
@@ -0,0 +1,40 @@
+.sectionContainer {
+ display: flex;
+ flex: 1 1 10%;
+ overflow: hidden;
+}
+
+.section {
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+}
+
+.left {
+ justify-content: flex-start;
+}
+
+.center {
+ justify-content: center;
+}
+
+.right {
+ justify-content: flex-end;
+}
+
+.overflowMenuButton {
+ composes: menuButton from 'Components/Menu/ToolbarMenuButton.css';
+}
+
+.overflowMenuItemIcon {
+ margin-right: 8px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .overflowMenuButton {
+ &::after {
+ margin-left: 0;
+ content: '\25BE';
+ }
+ }
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js
new file mode 100644
index 000000000..35ee586ec
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js
@@ -0,0 +1,221 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { forEach } from 'Helpers/elementChildren';
+import { align, icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import SpinnerIcon from 'Components/SpinnerIcon';
+import Measure from 'Components/Measure';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+import styles from './PageToolbarSection.css';
+
+const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
+const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
+const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
+const SEPARATOR_NAME = 'PageToolbarSeparator';
+
+function calculateOverflowItems(children, isMeasured, width, collapseButtons) {
+ let buttonCount = 0;
+ let separatorCount = 0;
+ const validChildren = [];
+
+ forEach(children, (child) => {
+ const name = child.type.name;
+
+ if (name === SEPARATOR_NAME) {
+ separatorCount++;
+ } else {
+ buttonCount++;
+ }
+
+ validChildren.push(child);
+ });
+
+ const buttonsWidth = buttonCount * BUTTON_WIDTH;
+ const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
+ const totalWidth = buttonsWidth + separatorsWidth;
+
+ // If the width of buttons and separators is less than
+ // the available width return all valid children.
+
+ if (
+ !isMeasured ||
+ !collapseButtons ||
+ totalWidth < width
+ ) {
+ return {
+ buttons: validChildren,
+ buttonCount,
+ overflowItems: []
+ };
+ }
+
+ const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1);
+ const buttons = [];
+ const overflowItems = [];
+ let actualButtons = 0;
+
+ // Return all buttons if only one is being pushed to the overflow menu.
+ if (buttonCount - 1 === maxButtons) {
+ return {
+ buttons: validChildren,
+ buttonCount,
+ overflowItems: []
+ };
+ }
+
+ validChildren.forEach((child, index) => {
+ if (actualButtons < maxButtons) {
+ if (child.type.name !== SEPARATOR_NAME) {
+ buttons.push(child);
+ actualButtons++;
+ }
+ } else if (child.type.name !== SEPARATOR_NAME) {
+ overflowItems.push(child.props);
+ }
+ });
+
+ return {
+ buttons,
+ buttonCount,
+ overflowItems
+ };
+}
+
+class PageToolbarSection extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMeasured: false,
+ width: 0,
+ buttons: [],
+ overflowItems: []
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({
+ isMeasured: true,
+ width
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ alignContent,
+ collapseButtons
+ } = this.props;
+
+ const {
+ isMeasured,
+ width
+ } = this.state;
+
+ const {
+ buttons,
+ buttonCount,
+ overflowItems
+ } = calculateOverflowItems(children, isMeasured, width, collapseButtons);
+
+ return (
+
+
+ {
+ isMeasured ?
+
+ {
+ buttons.map((button) => {
+ return button;
+ })
+ }
+
+ {
+ !!overflowItems.length &&
+
+
+
+
+ {
+ overflowItems.map((item) => {
+ const {
+ iconName,
+ spinningName,
+ label,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = item;
+
+ return (
+
+
+ {label}
+
+ );
+ })
+ }
+
+
+ }
+ :
+ null
+ }
+
+
+ );
+ }
+
+}
+
+PageToolbarSection.propTypes = {
+ children: PropTypes.node,
+ alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]),
+ collapseButtons: PropTypes.bool.isRequired
+};
+
+PageToolbarSection.defaultProps = {
+ alignContent: align.LEFT,
+ collapseButtons: true
+};
+
+export default PageToolbarSection;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css
new file mode 100644
index 000000000..968673593
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css
@@ -0,0 +1,12 @@
+.separator {
+ margin: 10px $toolbarSeparatorMargin;
+ height: 40px;
+ border-right: 1px solid #e5e5e5;
+ opacity: 0.35;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .separator {
+ margin: 10px 5px;
+ }
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js
new file mode 100644
index 000000000..754248f99
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js
@@ -0,0 +1,17 @@
+import React, { Component } from 'react';
+import styles from './PageToolbarSeparator.css';
+
+class PageToolbarSeparator extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+
+}
+
+export default PageToolbarSeparator;
diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css
new file mode 100644
index 000000000..2f0019043
--- /dev/null
+++ b/frontend/src/Components/ProgressBar.css
@@ -0,0 +1,93 @@
+.container {
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+ border-radius: 4px;
+ background-color: #f5f5f5;
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.progressBar {
+ position: relative;
+ z-index: 1;
+ float: left;
+ width: 0;
+ height: 100%;
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+ color: $white;
+ transition: width 0.6s ease;
+}
+
+.frontTextContainer {
+ z-index: 1;
+ color: $white;
+}
+
+.backTextContainer,
+.frontTextContainer {
+ position: absolute;
+ overflow: hidden;
+ width: 0;
+ height: 100%;
+}
+
+.backText,
+.frontText {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ font-size: 12px;
+ cursor: default;
+}
+
+.primary {
+ background-color: $primaryColor;
+}
+
+.danger {
+ background-color: $dangerColor;
+}
+
+.success {
+ background-color: $successColor;
+}
+
+.purple {
+ background-color: $purple;
+}
+
+.warning {
+ background-color: $warningColor;
+}
+
+.info {
+ background-color: $infoColor;
+}
+
+.small {
+ height: $progressBarSmallHeight;
+
+ .backText,
+ .frontText {
+ height: $progressBarSmallHeight;
+ }
+}
+
+.medium {
+ height: $progressBarMediumHeight;
+
+ .backText,
+ .frontText {
+ height: $progressBarMediumHeight;
+ }
+}
+
+.large {
+ height: $progressBarLargeHeight;
+
+ .backText,
+ .frontText {
+ height: $progressBarLargeHeight;
+ }
+}
diff --git a/frontend/src/Components/ProgressBar.js b/frontend/src/Components/ProgressBar.js
new file mode 100644
index 000000000..4f457d558
--- /dev/null
+++ b/frontend/src/Components/ProgressBar.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds, sizes } from 'Helpers/Props';
+import styles from './ProgressBar.css';
+
+function ProgressBar(props) {
+ const {
+ className,
+ containerClassName,
+ title,
+ progress,
+ precision,
+ showText,
+ text,
+ kind,
+ size,
+ width
+ } = props;
+
+ const progressPercent = `${progress.toFixed(precision)}%`;
+ const progressText = text || progressPercent;
+ const actualWidth = width ? `${width}px` : '100%';
+
+ return (
+
+ {
+ showText && !!width &&
+
+ }
+
+
+ {
+ showText &&
+
+ }
+
+ );
+}
+
+ProgressBar.propTypes = {
+ className: PropTypes.string,
+ containerClassName: PropTypes.string,
+ title: PropTypes.string,
+ progress: PropTypes.number.isRequired,
+ precision: PropTypes.number.isRequired,
+ showText: PropTypes.bool.isRequired,
+ text: PropTypes.string,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ size: PropTypes.oneOf(sizes.all).isRequired,
+ width: PropTypes.number
+};
+
+ProgressBar.defaultProps = {
+ className: styles.progressBar,
+ containerClassName: styles.container,
+ precision: 1,
+ showText: false,
+ kind: kinds.PRIMARY,
+ size: sizes.MEDIUM
+};
+
+export default ProgressBar;
diff --git a/frontend/src/Components/Router/Switch.js b/frontend/src/Components/Router/Switch.js
new file mode 100644
index 000000000..0c0004a50
--- /dev/null
+++ b/frontend/src/Components/Router/Switch.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Switch as RouterSwitch } from 'react-router-dom';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import { map } from 'Helpers/elementChildren';
+
+class Switch extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+ {
+ map(children, (child) => {
+ const {
+ path: childPath,
+ addUrlBase = true
+ } = child.props;
+
+ if (!childPath) {
+ return child;
+ }
+
+ const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
+
+ return React.cloneElement(child, { path });
+ })
+ }
+
+ );
+ }
+}
+
+Switch.propTypes = {
+ children: PropTypes.node.isRequired
+};
+
+export default Switch;
diff --git a/frontend/src/Components/Scroller/OverlayScroller.css b/frontend/src/Components/Scroller/OverlayScroller.css
new file mode 100644
index 000000000..707a9ac6f
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.css
@@ -0,0 +1,15 @@
+.scroller {
+ /* Placeholder */
+}
+
+.thumb {
+ min-height: 50px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background-color: $scrollbarBackgroundColor;
+ background-clip: padding-box;
+
+ &:hover {
+ background-color: $scrollbarHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/Components/Scroller/OverlayScroller.js b/frontend/src/Components/Scroller/OverlayScroller.js
new file mode 100644
index 000000000..23fe6058a
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Scrollbars } from 'react-custom-scrollbars';
+import { scrollDirections } from 'Helpers/Props';
+import styles from './OverlayScroller.css';
+
+class OverlayScroller extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scroller = null;
+ this._isScrolling = false;
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ scrollTop
+ } = this.props;
+
+ if (!this._isScrolling && scrollTop != null && scrollTop !== prevProps.scrollTop) {
+ this._scroller.scrollTop(scrollTop);
+ }
+ }
+
+ //
+ // Control
+
+ _setScrollRef = (ref) => {
+ this._scroller = ref;
+ }
+
+ _renderThumb = (props) => {
+ return (
+
+ );
+ }
+
+ _renderView = (props) => {
+ return (
+
+ );
+ }
+
+ //
+ // Listers
+
+ onScrollStart = () => {
+ this._isScrolling = true;
+ }
+
+ onScrollStop = () => {
+ this._isScrolling = false;
+ }
+
+ onScroll = (event) => {
+ const {
+ scrollTop,
+ scrollLeft
+ } = event.currentTarget;
+
+ this._isScrolling = true;
+ const onScroll = this.props.onScroll;
+
+ if (onScroll) {
+ onScroll({ scrollTop, scrollLeft });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ autoHide,
+ autoScroll,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+OverlayScroller.propTypes = {
+ className: PropTypes.string,
+ trackClassName: PropTypes.string,
+ scrollTop: PropTypes.number,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
+ autoHide: PropTypes.bool.isRequired,
+ autoScroll: PropTypes.bool.isRequired,
+ children: PropTypes.node,
+ onScroll: PropTypes.func
+};
+
+OverlayScroller.defaultProps = {
+ className: styles.scroller,
+ trackClassName: styles.thumb,
+ scrollDirection: scrollDirections.VERTICAL,
+ autoHide: false,
+ autoScroll: true
+};
+
+export default OverlayScroller;
diff --git a/frontend/src/Components/Scroller/Scroller.css b/frontend/src/Components/Scroller/Scroller.css
new file mode 100644
index 000000000..c8783a8de
--- /dev/null
+++ b/frontend/src/Components/Scroller/Scroller.css
@@ -0,0 +1,28 @@
+.scroller {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.none {
+ overflow-x: hidden;
+ overflow-y: hidden;
+}
+
+.vertical {
+ overflow-x: hidden;
+ overflow-y: scroll;
+
+ &.autoScroll {
+ overflow-y: auto;
+ }
+}
+
+.horizontal {
+ overflow-x: scroll;
+ overflow-y: hidden;
+
+ &.autoScroll {
+ overflow-x: auto;
+ }
+}
diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js
new file mode 100644
index 000000000..701ac0cf4
--- /dev/null
+++ b/frontend/src/Components/Scroller/Scroller.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { scrollDirections } from 'Helpers/Props';
+import styles from './Scroller.css';
+
+class Scroller extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scroller = null;
+ }
+
+ componentDidMount() {
+ const {
+ scrollTop
+ } = this.props;
+
+ if (this.props.scrollTop != null) {
+ this._scroller.scrollTop = scrollTop;
+ }
+ }
+
+ //
+ // Control
+
+ _setScrollerRef = (ref) => {
+ this._scroller = ref;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ scrollDirection,
+ autoScroll,
+ children,
+ scrollTop,
+ onScroll,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+Scroller.propTypes = {
+ className: PropTypes.string,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
+ autoScroll: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number,
+ children: PropTypes.node,
+ onScroll: PropTypes.func
+};
+
+Scroller.defaultProps = {
+ scrollDirection: scrollDirections.VERTICAL,
+ autoScroll: true
+};
+
+export default Scroller;
diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js
new file mode 100644
index 000000000..f0af952f5
--- /dev/null
+++ b/frontend/src/Components/SignalRConnector.js
@@ -0,0 +1,351 @@
+import $ from 'jquery';
+import 'signalr';
+import PropTypes from 'prop-types';
+import { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { repopulatePage } from 'Utilities/pagePopulator';
+import titleCase from 'Utilities/String/titleCase';
+import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions';
+import { setAppValue, setVersion } from 'Store/Actions/appActions';
+import { update, updateItem, removeItem } from 'Store/Actions/baseActions';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions';
+
+function getState(status) {
+ switch (status) {
+ case 0:
+ return 'connecting';
+ case 1:
+ return 'connected';
+ case 2:
+ return 'reconnecting';
+ case 4:
+ return 'disconnected';
+ default:
+ throw new Error(`invalid status ${status}`);
+ }
+}
+
+function isAppDisconnected(disconnectedTime) {
+ if (!disconnectedTime) {
+ return false;
+ }
+
+ return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180;
+}
+
+function getHandlerName(name) {
+ name = titleCase(name);
+ name = name.replace('/', '');
+
+ return `handle${name}`;
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.isReconnecting,
+ (state) => state.app.isDisconnected,
+ (state) => state.queue.paged.isPopulated,
+ (isReconnecting, isDisconnected, isQueuePopulated) => {
+ return {
+ isReconnecting,
+ isDisconnected,
+ isQueuePopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchCommands: fetchCommands,
+ dispatchUpdateCommand: updateCommand,
+ dispatchFinishCommand: finishCommand,
+ dispatchSetAppValue: setAppValue,
+ dispatchSetVersion: setVersion,
+ dispatchUpdate: update,
+ dispatchUpdateItem: updateItem,
+ dispatchRemoveItem: removeItem,
+ dispatchFetchHealth: fetchHealth,
+ dispatchFetchQueue: fetchQueue,
+ dispatchFetchQueueDetails: fetchQueueDetails,
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchFetchTags: fetchTags,
+ dispatchFetchTagDetails: fetchTagDetails
+};
+
+class SignalRConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] };
+ this.signalRconnection = null;
+ this.retryInterval = 1;
+ this.retryTimeoutId = null;
+ this.disconnectedTime = null;
+ }
+
+ componentDidMount() {
+ console.log('Starting signalR');
+
+ this.signalRconnection = $.connection('/signalr', { apiKey: window.Radarr.apiKey });
+
+ this.signalRconnection.stateChanged(this.onStateChanged);
+ this.signalRconnection.received(this.onReceived);
+ this.signalRconnection.reconnecting(this.onReconnecting);
+ this.signalRconnection.disconnected(this.onDisconnected);
+
+ this.signalRconnection.start(this.signalRconnectionOptions);
+ }
+
+ componentWillUnmount() {
+ if (this.retryTimeoutId) {
+ this.retryTimeoutId = clearTimeout(this.retryTimeoutId);
+ }
+
+ this.signalRconnection.stop();
+ this.signalRconnection = null;
+ }
+
+ //
+ // Control
+
+ retryConnection = () => {
+ if (isAppDisconnected(this.disconnectedTime)) {
+ this.setState({
+ isDisconnected: true
+ });
+ }
+
+ this.retryTimeoutId = setTimeout(() => {
+ if (!this.signalRconnection) {
+ console.error('signalR: Connection was disposed');
+ return;
+ }
+
+ this.signalRconnection.start(this.signalRconnectionOptions);
+ this.retryInterval = Math.min(this.retryInterval + 1, 10);
+ }, this.retryInterval * 1000);
+ }
+
+ handleMessage = (message) => {
+ const {
+ name,
+ body
+ } = message;
+
+ const handler = this[getHandlerName(name)];
+
+ if (handler) {
+ handler(body);
+ return;
+ }
+
+ console.error(`signalR: Unable to find handler for ${name}`);
+ }
+
+ handleCalendar = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'calendar',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleCommand = (body) => {
+ if (body.action === 'sync') {
+ this.props.dispatchFetchCommands();
+ return;
+ }
+
+ const resource = body.resource;
+ const status = resource.status;
+
+ // Both sucessful and failed commands need to be
+ // completed, otherwise they spin until they timeout.
+
+ if (status === 'completed' || status === 'failed') {
+ this.props.dispatchFinishCommand(resource);
+ } else {
+ this.props.dispatchUpdateCommand(resource);
+ }
+ }
+
+ handleMoviefile = (body) => {
+ const section = 'movieFiles';
+
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...body.resource });
+
+ // Repopulate the page to handle recently imported file
+ repopulatePage('movieFileUpdated');
+ } else if (body.action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: body.resource.id });
+ }
+ }
+
+ handleHealth = () => {
+ this.props.dispatchFetchHealth();
+ }
+
+ handleMovie = (body) => {
+ const action = body.action;
+ const section = 'movies';
+
+ if (action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...body.resource });
+ } else if (action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: body.resource.id });
+ }
+ }
+
+ handleQueue = () => {
+ if (this.props.isQueuePopulated) {
+ this.props.dispatchFetchQueue();
+ }
+ }
+
+ handleQueueDetails = () => {
+ this.props.dispatchFetchQueueDetails();
+ }
+
+ handleQueueStatus = (body) => {
+ this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
+ }
+
+ handleRootfolder = () => {
+ this.props.dispatchFetchRootFolders();
+ }
+
+ handleVersion = (body) => {
+ const version = body.Version;
+
+ this.props.dispatchSetVersion({ version });
+ }
+
+ handleSystemTask = () => {
+ // No-op for now, we may want this later
+ }
+
+ handleTag = (body) => {
+ if (body.action === 'sync') {
+ this.props.dispatchFetchTags();
+ this.props.dispatchFetchTagDetails();
+ return;
+ }
+ }
+
+ //
+ // Listeners
+
+ onStateChanged = (change) => {
+ const state = getState(change.newState);
+ console.log(`signalR: ${state}`);
+
+ if (state === 'connected') {
+ // Clear disconnected time
+ this.disconnectedTime = null;
+
+ const {
+ dispatchFetchCommands,
+ dispatchSetAppValue
+ } = this.props;
+
+ // Repopulate the page (if a repopulator is set) to ensure things
+ // are in sync after reconnecting.
+
+ if (this.props.isReconnecting || this.props.isDisconnected) {
+ dispatchFetchCommands();
+ repopulatePage();
+ }
+
+ dispatchSetAppValue({
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false,
+ isRestarting: false
+ });
+
+ this.retryInterval = 5;
+
+ if (this.retryTimeoutId) {
+ clearTimeout(this.retryTimeoutId);
+ }
+ }
+ }
+
+ onReceived = (message) => {
+ console.debug('signalR: received', message.name, message.body);
+
+ this.handleMessage(message);
+ }
+
+ onReconnecting = () => {
+ if (window.Radarr.unloading) {
+ return;
+ }
+
+ if (!this.disconnectedTime) {
+ this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
+ }
+
+ this.props.dispatchSetAppValue({
+ isReconnecting: true
+ });
+ }
+
+ onDisconnected = () => {
+ if (window.Radarr.unloading) {
+ return;
+ }
+
+ if (!this.disconnectedTime) {
+ this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
+ }
+
+ this.props.dispatchSetAppValue({
+ isConnected: false,
+ isReconnecting: true,
+ isDisconnected: isAppDisconnected(this.disconnectedTime)
+ });
+
+ this.retryConnection();
+ }
+
+ //
+ // Render
+
+ render() {
+ return null;
+ }
+}
+
+SignalRConnector.propTypes = {
+ isReconnecting: PropTypes.bool.isRequired,
+ isDisconnected: PropTypes.bool.isRequired,
+ isQueuePopulated: PropTypes.bool.isRequired,
+ dispatchFetchCommands: PropTypes.func.isRequired,
+ dispatchUpdateCommand: PropTypes.func.isRequired,
+ dispatchFinishCommand: PropTypes.func.isRequired,
+ dispatchSetAppValue: PropTypes.func.isRequired,
+ dispatchSetVersion: PropTypes.func.isRequired,
+ dispatchUpdate: PropTypes.func.isRequired,
+ dispatchUpdateItem: PropTypes.func.isRequired,
+ dispatchRemoveItem: PropTypes.func.isRequired,
+ dispatchFetchHealth: PropTypes.func.isRequired,
+ dispatchFetchQueue: PropTypes.func.isRequired,
+ dispatchFetchQueueDetails: PropTypes.func.isRequired,
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchFetchTags: PropTypes.func.isRequired,
+ dispatchFetchTagDetails: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector);
diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js
new file mode 100644
index 000000000..d21674d9e
--- /dev/null
+++ b/frontend/src/Components/SpinnerIcon.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from './Icon';
+
+function SpinnerIcon(props) {
+ const {
+ name,
+ spinningName,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+}
+
+SpinnerIcon.propTypes = {
+ name: PropTypes.object.isRequired,
+ spinningName: PropTypes.object.isRequired,
+ isSpinning: PropTypes.bool.isRequired
+};
+
+SpinnerIcon.defaultProps = {
+ spinningName: icons.SPINNER
+};
+
+export default SpinnerIcon;
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css
new file mode 100644
index 000000000..7be20ce5d
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.css
@@ -0,0 +1,5 @@
+.cell {
+ composes: cell from './TableRowCell.css';
+
+ width: 180px;
+}
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js
new file mode 100644
index 000000000..93004b447
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import TableRowCell from './TableRowCell';
+import styles from './RelativeDateCell.css';
+
+function RelativeDateCell(props) {
+ const {
+ className,
+ date,
+ includeSeconds,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ component: Component,
+ dispatch,
+ ...otherProps
+ } = props;
+
+ if (!date) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })}
+
+ );
+}
+
+RelativeDateCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ date: PropTypes.string,
+ includeSeconds: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ component: PropTypes.func,
+ dispatch: PropTypes.func
+};
+
+RelativeDateCell.defaultProps = {
+ className: styles.cell,
+ includeSeconds: false,
+ component: TableRowCell
+};
+
+export default RelativeDateCell;
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js
new file mode 100644
index 000000000..ed996abbe
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import RelativeDateCell from './RelativeDateCell';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return _.pick(uiSettings, [
+ 'showRelativeDates',
+ 'shortDateFormat',
+ 'longDateFormat',
+ 'timeFormat'
+ ]);
+ }
+ );
+}
+
+export default connect(createMapStateToProps, null)(RelativeDateCell);
diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css
new file mode 100644
index 000000000..1c3e6fc5a
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCell.css
@@ -0,0 +1,11 @@
+.cell {
+ padding: 8px;
+ border-top: 1px solid #eee;
+ line-height: 1.52857143;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .cell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/Cells/TableRowCell.js b/frontend/src/Components/Table/Cells/TableRowCell.js
new file mode 100644
index 000000000..f66bbf3aa
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCell.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './TableRowCell.css';
+
+class TableRowCell extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+TableRowCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
+};
+
+TableRowCell.defaultProps = {
+ className: styles.cell
+};
+
+export default TableRowCell;
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.css b/frontend/src/Components/Table/Cells/TableRowCellButton.css
new file mode 100644
index 000000000..f01e7cba6
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css
@@ -0,0 +1,4 @@
+.cell {
+ composes: cell from './TableRowCell.css';
+ composes: link from 'Components/Link/Link.css';
+}
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js
new file mode 100644
index 000000000..ff50d3bc9
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+import TableRowCell from './TableRowCell';
+import styles from './TableRowCellButton.css';
+
+function TableRowCellButton({ className, ...otherProps }) {
+ return (
+
+ );
+}
+
+TableRowCellButton.propTypes = {
+ className: PropTypes.string.isRequired
+};
+
+TableRowCellButton.defaultProps = {
+ className: styles.cell
+};
+
+export default TableRowCellButton;
diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css b/frontend/src/Components/Table/Cells/TableSelectCell.css
new file mode 100644
index 000000000..21ab944d7
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.css
@@ -0,0 +1,11 @@
+.selectCell {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js
new file mode 100644
index 000000000..9c10f4444
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import TableRowCell from './TableRowCell';
+import styles from './TableSelectCell.css';
+
+class TableSelectCell extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ id,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value: isSelected });
+ }
+
+ componentWillUnmount() {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value: null });
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ value, shiftKey }, a, b, c, d) => {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value, shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ id,
+ isSelected,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+TableSelectCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+TableSelectCell.defaultProps = {
+ className: styles.selectCell,
+ isSelected: false
+};
+
+export default TableSelectCell;
diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css
new file mode 100644
index 000000000..e4cffe1c4
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css
@@ -0,0 +1,14 @@
+.cell {
+ @add-mixin truncate;
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ flex-grow: 0;
+ flex-shrink: 1;
+ white-space: nowrap;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .cell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.js b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js
new file mode 100644
index 000000000..42999216f
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableRowCell.css';
+
+function VirtualTableRowCell(props) {
+ const {
+ className,
+ children
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableRowCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
+};
+
+VirtualTableRowCell.defaultProps = {
+ className: styles.cell
+};
+
+export default VirtualTableRowCell;
diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css
new file mode 100644
index 000000000..e1016aa8a
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css
@@ -0,0 +1,11 @@
+.cell {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 36px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js
new file mode 100644
index 000000000..a773aab58
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableRowCell from './VirtualTableRowCell';
+import styles from './VirtualTableSelectCell.css';
+
+export function virtualTableSelectCellRenderer(cellProps) {
+ const {
+ cellKey,
+ rowData,
+ columnData,
+ ...otherProps
+ } = cellProps;
+
+ return (
+
+ );
+}
+
+class VirtualTableSelectCell extends Component {
+
+ //
+ // Listeners
+
+ onChange = ({ value, shiftKey }) => {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value, shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ inputClassName,
+ id,
+ isSelected,
+ isDisabled,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+VirtualTableSelectCell.propTypes = {
+ inputClassName: PropTypes.string.isRequired,
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+VirtualTableSelectCell.defaultProps = {
+ inputClassName: styles.input,
+ isSelected: false
+};
+
+export default VirtualTableSelectCell;
diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css
new file mode 100644
index 000000000..46d49826a
--- /dev/null
+++ b/frontend/src/Components/Table/Table.css
@@ -0,0 +1,16 @@
+.tableContainer {
+ overflow-x: auto;
+}
+
+.table {
+ max-width: 100%;
+ width: 100%;
+ border-collapse: collapse;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .tableContainer {
+ overflow-y: hidden;
+ width: 100%;
+ }
+}
diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js
new file mode 100644
index 000000000..612d95b8c
--- /dev/null
+++ b/frontend/src/Components/Table/Table.js
@@ -0,0 +1,129 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, scrollDirections } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import Scroller from 'Components/Scroller/Scroller';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+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;
+ }, {});
+}
+
+function Table(props) {
+ const {
+ className,
+ selectAll,
+ columns,
+ optionsComponent,
+ pageSize,
+ canModifyColumns,
+ children,
+ onSortPress,
+ onTableOptionChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ {
+ selectAll &&
+
+ }
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (
+ (name === 'actions' || name === 'details') &&
+ onTableOptionChange
+ ) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {column.label}
+
+ );
+ })
+ }
+
+
+ {children}
+
+
+ );
+}
+
+Table.propTypes = {
+ className: PropTypes.string,
+ selectAll: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ optionsComponent: PropTypes.func,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func,
+ onTableOptionChange: PropTypes.func
+};
+
+Table.defaultProps = {
+ className: styles.table,
+ selectAll: false
+};
+
+export default Table;
diff --git a/frontend/src/Components/Table/TableBody.js b/frontend/src/Components/Table/TableBody.js
new file mode 100644
index 000000000..5cc60d6f4
--- /dev/null
+++ b/frontend/src/Components/Table/TableBody.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+
+class TableBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+ {children}
+ );
+ }
+
+}
+
+TableBody.propTypes = {
+ children: PropTypes.node
+};
+
+export default TableBody;
diff --git a/frontend/src/Components/Table/TableHeader.js b/frontend/src/Components/Table/TableHeader.js
new file mode 100644
index 000000000..81943e919
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeader.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+
+class TableHeader extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+TableHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default TableHeader;
diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css
new file mode 100644
index 000000000..c2c4f58c8
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeaderCell.css
@@ -0,0 +1,16 @@
+.headerCell {
+ padding: 8px;
+ border: none !important;
+ text-align: left;
+ font-weight: bold;
+}
+
+.sortIcon {
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .headerCell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js
new file mode 100644
index 000000000..e4739e63f
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeaderCell.js
@@ -0,0 +1,96 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './TableHeaderCell.css';
+
+class TableHeaderCell extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ fixedSortDirection
+ } = this.props;
+
+ if (fixedSortDirection) {
+ this.props.onSortPress(name, fixedSortDirection);
+ } else {
+ this.props.onSortPress(name);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ name,
+ columnLabel,
+ isSortable,
+ isVisible,
+ isModifiable,
+ sortKey,
+ sortDirection,
+ fixedSortDirection,
+ children,
+ onSortPress,
+ ...otherProps
+ } = this.props;
+
+ const isSorting = isSortable && sortKey === name;
+ const sortIcon = sortDirection === sortDirections.ASCENDING ?
+ icons.SORT_ASCENDING :
+ icons.SORT_DESCENDING;
+
+ return (
+ isSortable ?
+
+ {children}
+
+ {
+ isSorting &&
+
+ }
+ :
+
+
+ {children}
+
+ );
+ }
+}
+
+TableHeaderCell.propTypes = {
+ className: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ columnLabel: PropTypes.string,
+ isSortable: PropTypes.bool,
+ isVisible: PropTypes.bool,
+ isModifiable: PropTypes.bool,
+ sortKey: PropTypes.string,
+ fixedSortDirection: PropTypes.string,
+ sortDirection: PropTypes.string,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func
+};
+
+TableHeaderCell.defaultProps = {
+ className: styles.headerCell,
+ isSortable: false
+};
+
+export default TableHeaderCell;
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css
new file mode 100644
index 000000000..204773c3d
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css
@@ -0,0 +1,48 @@
+.column {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+}
+
+.checkContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 7px;
+ margin-left: 8px;
+}
+
+.label {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: 36px;
+ cursor: pointer;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.notDragable {
+ padding: 4px 0;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
new file mode 100644
index 000000000..a986be615
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TableOptionsColumn.css';
+
+function TableOptionsColumn(props) {
+ const {
+ name,
+ label,
+ isVisible,
+ isModifiable,
+ isDragging,
+ connectDragSource,
+ onVisibleChange
+ } = props;
+
+ return (
+
+
+
+
+ {label}
+
+
+ {
+ !!connectDragSource &&
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+ );
+}
+
+TableOptionsColumn.propTypes = {
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ isModifiable: PropTypes.bool.isRequired,
+ index: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ onVisibleChange: PropTypes.func.isRequired
+};
+
+export default TableOptionsColumn;
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js
new file mode 100644
index 000000000..b1d016529
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { TABLE_COLUMN } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import TableOptionsColumn from './TableOptionsColumn';
+import styles from './TableOptionsColumnDragPreview.css';
+
+const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
+const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class TableOptionsColumnDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== TABLE_COLUMN) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ return (
+
+
+
+ );
+ }
+}
+
+TableOptionsColumnDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview);
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css
new file mode 100644
index 000000000..9354a35c0
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css
@@ -0,0 +1,18 @@
+.columnDragSource {
+ padding: 4px 0;
+}
+
+.columnPlaceholder {
+ width: 100%;
+ height: 36px;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.columnPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.columnPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
new file mode 100644
index 000000000..80f03e430
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { TABLE_COLUMN } from 'Helpers/dragTypes';
+import TableOptionsColumn from './TableOptionsColumn';
+import styles from './TableOptionsColumnDragSource.css';
+
+const columnDragSource = {
+ beginDrag(column) {
+ return column;
+ },
+
+ endDrag(props, monitor, component) {
+ props.onColumnDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const columnDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().index;
+ const hoverIndex = props.index;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ // When moving up, only trigger if drag position is above 50% and
+ // when moving down, only trigger if drag position is below 50%.
+ // If we're moving down the hoverIndex needs to be increased
+ // by one so it's ordered properly. Otherwise the hoverIndex will work.
+
+ // Dragging downwards
+ if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
+ return;
+ }
+
+ // Dragging upwards
+ if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
+ return;
+ }
+
+ props.onColumnDragMove(dragIndex, hoverIndex);
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class TableOptionsColumnDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ label,
+ isVisible,
+ isModifiable,
+ index,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ onVisibleChange
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+TableOptionsColumnDragSource.propTypes = {
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ isModifiable: PropTypes.bool.isRequired,
+ index: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onVisibleChange: PropTypes.func.isRequired,
+ onColumnDragMove: PropTypes.func.isRequired,
+ onColumnDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ TABLE_COLUMN,
+ columnDropTarget,
+ collectDropTarget
+)(DragSource(
+ TABLE_COLUMN,
+ columnDragSource,
+ collectDragSource
+)(TableOptionsColumnDragSource));
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.css b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css
new file mode 100644
index 000000000..35544f32b
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css
@@ -0,0 +1,5 @@
+.columns {
+ margin-top: 10px;
+ width: 100%;
+ user-select: none;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
new file mode 100644
index 000000000..351d827ca
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
@@ -0,0 +1,252 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import TableOptionsColumn from './TableOptionsColumn';
+import TableOptionsColumnDragSource from './TableOptionsColumnDragSource';
+import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview';
+import styles from './TableOptionsModal.css';
+
+class TableOptionsModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPageSize: !!props.pageSize,
+ pageSize: props.pageSize,
+ pageSizeError: null,
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.pageSize !== this.state.pageSize) {
+ this.setState({ pageSize: this.props.pageSize });
+ }
+ }
+
+ //
+ // Listeners
+
+ onPageSizeChange = ({ value }) => {
+ let pageSizeError = null;
+
+ if (value < 5) {
+ pageSizeError = 'Page size must be at least 5';
+ } else if (value > 250) {
+ pageSizeError = 'Page size must not exceed 250';
+ } else {
+ this.props.onTableOptionChange({ pageSize: value });
+ }
+
+ this.setState({
+ pageSize: value,
+ pageSizeError
+ });
+ }
+
+ onVisibleChange = ({ name, value }) => {
+ const columns = _.cloneDeep(this.props.columns);
+
+ const column = _.find(columns, { name });
+ column.isVisible = value;
+
+ this.props.onTableOptionChange({ columns });
+ }
+
+ onColumnDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onColumnDragEnd = ({ id }, didDrop) => {
+ const {
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ const columns = _.cloneDeep(this.props.columns);
+ const items = columns.splice(dragIndex, 1);
+ columns.splice(dropIndex, 0, items[0]);
+
+ this.props.onTableOptionChange({ columns });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ columns,
+ canModifyColumns,
+ optionsComponent: OptionsComponent,
+ onTableOptionChange,
+ onModalClose
+ } = this.props;
+
+ const {
+ hasPageSize,
+ pageSize,
+ pageSizeError,
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex < dragIndex;
+ const isDraggingDown = isDragging && dropIndex > dragIndex;
+
+ return (
+
+
+
+ Table Options
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+TableOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool.isRequired,
+ optionsComponent: PropTypes.func,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+TableOptionsModal.defaultProps = {
+ canModifyColumns: true
+};
+
+export default DragDropContext(HTML5Backend)(TableOptionsModal);
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js
new file mode 100644
index 000000000..ff2b8538b
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import TableOptionsModal from './TableOptionsModal';
+
+class TableOptionsModalWrapper 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,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {
+ React.cloneElement(children, { onPress: this.onTableOptionsPress })
+ }
+
+
+
+ );
+ }
+}
+
+TableOptionsModalWrapper.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ children: PropTypes.node.isRequired
+};
+
+export default TableOptionsModalWrapper;
diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css
new file mode 100644
index 000000000..17d300fb9
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.css
@@ -0,0 +1,77 @@
+.pager {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.loadingContainer,
+.controlsContainer,
+.recordsContainer {
+ flex: 0 1 33%;
+}
+
+.controlsContainer {
+ display: flex;
+ justify-content: center;
+}
+
+.recordsContainer {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin: 0;
+ margin-left: 5px;
+ text-align: left;
+}
+
+.controls {
+ display: flex;
+ align-items: center;
+ text-align: center;
+}
+
+.pageNumber {
+ line-height: 30px;
+}
+
+.pageLink {
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+}
+
+.records {
+ color: $disabledColor;
+}
+
+.disabledPageButton {
+ color: $disabledColor;
+}
+
+.pageSelect {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ padding: 0 2px;
+ height: 25px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .pager {
+ flex-wrap: wrap;
+ }
+
+ .loadingContainer,
+ .recordsContainer {
+ flex: 0 1 50%;
+ }
+
+ .controlsContainer {
+ flex: 0 1 100%;
+ order: -1;
+ }
+}
diff --git a/frontend/src/Components/Table/TablePager.js b/frontend/src/Components/Table/TablePager.js
new file mode 100644
index 000000000..3c7c5a8f1
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.js
@@ -0,0 +1,180 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectInput from 'Components/Form/SelectInput';
+import styles from './TablePager.css';
+
+class TablePager extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isShowingPageSelect: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onOpenPageSelectClick = () => {
+ this.setState({ isShowingPageSelect: true });
+ }
+
+ onPageSelect = ({ value: page }) => {
+ this.setState({ isShowingPageSelect: false });
+ this.props.onPageSelect(parseInt(page));
+ }
+
+ onPageSelectBlur = () => {
+ this.setState({ isShowingPageSelect: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ page,
+ totalPages,
+ totalRecords,
+ isFetching,
+ onFirstPagePress,
+ onPreviousPagePress,
+ onNextPagePress,
+ onLastPagePress
+ } = this.props;
+
+ const isShowingPageSelect = this.state.isShowingPageSelect;
+ const pages = Array.from(new Array(totalPages), (x, i) => {
+ const pageNumber = i + 1;
+
+ return {
+ key: pageNumber,
+ value: pageNumber
+ };
+ });
+
+ if (!page) {
+ return null;
+ }
+
+ const isFirstPage = page === 1;
+ const isLastPage = page === totalPages;
+
+ return (
+
+
+ {
+ isFetching &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !isShowingPageSelect &&
+
+ {page} / {totalPages}
+
+ }
+
+ {
+ isShowingPageSelect &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total records: {totalRecords}
+
+
+
+ );
+ }
+
+}
+
+TablePager.propTypes = {
+ page: PropTypes.number,
+ totalPages: PropTypes.number,
+ totalRecords: PropTypes.number,
+ isFetching: PropTypes.bool,
+ onFirstPagePress: PropTypes.func.isRequired,
+ onPreviousPagePress: PropTypes.func.isRequired,
+ onNextPagePress: PropTypes.func.isRequired,
+ onLastPagePress: PropTypes.func.isRequired,
+ onPageSelect: PropTypes.func.isRequired
+};
+
+export default TablePager;
diff --git a/frontend/src/Components/Table/TableRow.css b/frontend/src/Components/Table/TableRow.css
new file mode 100644
index 000000000..dcc6ad8cf
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.css
@@ -0,0 +1,7 @@
+.row {
+ transition: background-color 500ms;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/Components/Table/TableRow.js b/frontend/src/Components/Table/TableRow.js
new file mode 100644
index 000000000..c76083183
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './TableRow.css';
+
+function TableRow(props) {
+ const {
+ className,
+ children,
+ overlayContent,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+TableRow.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ overlayContent: PropTypes.bool
+};
+
+TableRow.defaultProps = {
+ className: styles.row
+};
+
+export default TableRow;
diff --git a/frontend/src/Components/Table/TableRowButton.css b/frontend/src/Components/Table/TableRowButton.css
new file mode 100644
index 000000000..70a2238ca
--- /dev/null
+++ b/frontend/src/Components/Table/TableRowButton.css
@@ -0,0 +1,4 @@
+.row {
+ composes: link from 'Components/Link/Link.css';
+ composes: row from './TableRow.css';
+}
diff --git a/frontend/src/Components/Table/TableRowButton.js b/frontend/src/Components/Table/TableRowButton.js
new file mode 100644
index 000000000..7ff679673
--- /dev/null
+++ b/frontend/src/Components/Table/TableRowButton.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import TableRow from './TableRow';
+import styles from './TableRowButton.css';
+
+function TableRowButton(props) {
+ return (
+
+ );
+}
+
+export default TableRowButton;
diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.css b/frontend/src/Components/Table/TableSelectAllHeaderCell.css
new file mode 100644
index 000000000..6090e6e9c
--- /dev/null
+++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.css
@@ -0,0 +1,11 @@
+.selectAllHeaderCell {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ width: 30px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.js b/frontend/src/Components/Table/TableSelectAllHeaderCell.js
new file mode 100644
index 000000000..c889c32ae
--- /dev/null
+++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableHeaderCell from './TableHeaderCell';
+import styles from './TableSelectAllHeaderCell.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+function TableSelectAllHeaderCell(props) {
+ const {
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ const value = getValue(allSelected, allUnselected);
+
+ return (
+
+
+
+ );
+}
+
+TableSelectAllHeaderCell.propTypes = {
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default TableSelectAllHeaderCell;
diff --git a/frontend/src/Components/Table/VirtualTable.css b/frontend/src/Components/Table/VirtualTable.css
new file mode 100644
index 000000000..3287c5643
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.css
@@ -0,0 +1,3 @@
+.tableContainer {
+ width: 100%;
+}
diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js
new file mode 100644
index 000000000..a13807647
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.js
@@ -0,0 +1,167 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { WindowScroller } from 'react-virtualized';
+import { scrollDirections } from 'Helpers/Props';
+import Measure from 'Components/Measure';
+import Scroller from 'Components/Scroller/Scroller';
+import VirtualTableBody from './VirtualTableBody';
+import styles from './VirtualTable.css';
+
+const ROW_HEIGHT = 38;
+
+function overscanIndicesGetter(options) {
+ const {
+ cellCount,
+ overscanCellsCount,
+ startIndex,
+ stopIndex
+ } = options;
+
+ // The default getter takes the scroll direction into account,
+ // but that can cause issues. Ignore the scroll direction and
+ // always over return more items.
+
+ const overscanStartIndex = startIndex - overscanCellsCount;
+ const overscanStopIndex = stopIndex + overscanCellsCount;
+
+ return {
+ overscanStartIndex: Math.max(0, overscanStartIndex),
+ overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex)
+ };
+}
+
+class VirtualTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0
+ };
+
+ this._isInitialized = false;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps, preState) {
+ const scrollIndex = this.props.scrollIndex;
+
+ if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
+ const scrollTop = (scrollIndex + 1) * ROW_HEIGHT + 20;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+
+ //
+ // Control
+
+ rowGetter = ({ index }) => {
+ return this.props.items[index];
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({
+ width
+ });
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ items,
+ isSmallScreen,
+ header,
+ headerHeight,
+ scrollTop,
+ rowRenderer,
+ onScroll,
+ ...otherProps
+ } = this.props;
+
+ const {
+ width
+ } = this.state;
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ {header}
+
+
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+VirtualTable.propTypes = {
+ className: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ scrollIndex: PropTypes.number,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ header: PropTypes.node.isRequired,
+ headerHeight: PropTypes.number.isRequired,
+ rowRenderer: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+VirtualTable.defaultProps = {
+ className: styles.tableContainer,
+ headerHeight: 38,
+ onRender: () => {}
+};
+
+export default VirtualTable;
diff --git a/frontend/src/Components/Table/VirtualTableBody.css b/frontend/src/Components/Table/VirtualTableBody.css
new file mode 100644
index 000000000..12768646d
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableBody.css
@@ -0,0 +1,3 @@
+.tableBodyContainer {
+ position: relative;
+}
diff --git a/frontend/src/Components/Table/VirtualTableBody.js b/frontend/src/Components/Table/VirtualTableBody.js
new file mode 100644
index 000000000..de88bd03c
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableBody.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Grid } from 'react-virtualized';
+import styles from './VirtualTableBody.css';
+
+class VirtualTableBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+VirtualTableBody.propTypes = {
+ className: PropTypes.string.isRequired
+};
+
+VirtualTableBody.defaultProps = {
+ className: styles.tableBodyContainer
+};
+
+export default VirtualTableBody;
diff --git a/frontend/src/Components/Table/VirtualTableHeader.css b/frontend/src/Components/Table/VirtualTableHeader.css
new file mode 100644
index 000000000..4b757c1f8
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeader.css
@@ -0,0 +1,3 @@
+.header {
+ display: flex;
+}
diff --git a/frontend/src/Components/Table/VirtualTableHeader.js b/frontend/src/Components/Table/VirtualTableHeader.js
new file mode 100644
index 000000000..cf6a0f47b
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeader.js
@@ -0,0 +1,17 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableHeader.css';
+
+function VirtualTableHeader({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default VirtualTableHeader;
diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.css b/frontend/src/Components/Table/VirtualTableHeaderCell.css
new file mode 100644
index 000000000..c2c4f58c8
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeaderCell.css
@@ -0,0 +1,16 @@
+.headerCell {
+ padding: 8px;
+ border: none !important;
+ text-align: left;
+ font-weight: bold;
+}
+
+.sortIcon {
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .headerCell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.js b/frontend/src/Components/Table/VirtualTableHeaderCell.js
new file mode 100644
index 000000000..bf51062e9
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeaderCell.js
@@ -0,0 +1,107 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './VirtualTableHeaderCell.css';
+
+export function headerRenderer(headerProps) {
+ const {
+ columnData = {},
+ dataKey,
+ label
+ } = headerProps;
+
+ return (
+
+ {label}
+
+ );
+}
+
+class VirtualTableHeaderCell extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ fixedSortDirection
+ } = this.props;
+
+ if (fixedSortDirection) {
+ this.props.onSortPress(name, fixedSortDirection);
+ } else {
+ this.props.onSortPress(name);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ name,
+ isSortable,
+ sortKey,
+ sortDirection,
+ fixedSortDirection,
+ children,
+ onSortPress,
+ ...otherProps
+ } = this.props;
+
+ const isSorting = isSortable && sortKey === name;
+ const sortIcon = sortDirection === sortDirections.ASCENDING ?
+ icons.SORT_ASCENDING :
+ icons.SORT_DESCENDING;
+
+ return (
+ isSortable ?
+
+ {children}
+
+ {
+ isSorting &&
+
+ }
+ :
+
+
+ {children}
+
+ );
+ }
+}
+
+VirtualTableHeaderCell.propTypes = {
+ className: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ isSortable: PropTypes.bool,
+ sortKey: PropTypes.string,
+ fixedSortDirection: PropTypes.string,
+ sortDirection: PropTypes.string,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func
+};
+
+VirtualTableHeaderCell.defaultProps = {
+ className: styles.headerCell,
+ isSortable: false
+};
+
+export default VirtualTableHeaderCell;
diff --git a/frontend/src/Components/Table/VirtualTableRow.css b/frontend/src/Components/Table/VirtualTableRow.css
new file mode 100644
index 000000000..f4c825b64
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRow.css
@@ -0,0 +1,14 @@
+.row {
+ display: flex;
+ transition: background-color 500ms;
+
+ &:hover {
+ background-color: #fafbfc;
+ }
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .row {
+ overflow-x: visible !important;
+ }
+}
diff --git a/frontend/src/Components/Table/VirtualTableRow.js b/frontend/src/Components/Table/VirtualTableRow.js
new file mode 100644
index 000000000..0a423902e
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRow.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableRow.css';
+
+function VirtualTableRow(props) {
+ const {
+ className,
+ children,
+ style,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableRow.propTypes = {
+ className: PropTypes.string.isRequired,
+ style: PropTypes.object.isRequired,
+ children: PropTypes.node
+};
+
+VirtualTableRow.defaultProps = {
+ className: styles.row
+};
+
+export default VirtualTableRow;
diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css
new file mode 100644
index 000000000..1f3f7fb30
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css
@@ -0,0 +1,11 @@
+.selectAllHeaderCell {
+ composes: headerCell from 'Components/Table/TableHeaderCell.css';
+
+ flex: 0 0 36px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js
new file mode 100644
index 000000000..58b246763
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableHeaderCell from './VirtualTableHeaderCell';
+import styles from './VirtualTableSelectAllHeaderCell.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+function VirtualTableSelectAllHeaderCell(props) {
+ const {
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ const value = getValue(allSelected, allUnselected);
+
+ return (
+
+
+
+ );
+}
+
+VirtualTableSelectAllHeaderCell.propTypes = {
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default VirtualTableSelectAllHeaderCell;
diff --git a/frontend/src/Components/TagList.css b/frontend/src/Components/TagList.css
new file mode 100644
index 000000000..c1e5567bd
--- /dev/null
+++ b/frontend/src/Components/TagList.css
@@ -0,0 +1,3 @@
+.tags {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js
new file mode 100644
index 000000000..485651bdc
--- /dev/null
+++ b/frontend/src/Components/TagList.js
@@ -0,0 +1,38 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from './Label';
+import styles from './TagList.css';
+
+function TagList({ tags, tagList }) {
+ return (
+
+ {
+ tags.map((t) => {
+ const tag = _.find(tagList, { id: t });
+
+ if (!tag) {
+ return null;
+ }
+
+ return (
+
+ {tag.label}
+
+ );
+ })
+ }
+
+ );
+}
+
+TagList.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default TagList;
diff --git a/frontend/src/Components/TagListConnector.js b/frontend/src/Components/TagListConnector.js
new file mode 100644
index 000000000..be7e618e3
--- /dev/null
+++ b/frontend/src/Components/TagListConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagList from './TagList';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagsSelector(),
+ (tagList) => {
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(TagList);
diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css
new file mode 100644
index 000000000..f7b87f0b9
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.css
@@ -0,0 +1,105 @@
+.tether {
+ z-index: 2000;
+}
+
+.popoverContainer {
+ margin: 10px 15px;
+}
+
+.popover {
+ position: relative;
+ background-color: $white;
+ box-shadow: 0 5px 10px $popoverShadowColor;
+}
+
+.arrow,
+.arrow::after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-width: 11px;
+ border-style: solid;
+ border-color: transparent;
+}
+
+.arrow::after {
+ border-width: 10px;
+ content: '';
+}
+
+.top {
+ bottom: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-color: $popoverArrowBorderColor;
+ border-bottom-width: 0;
+
+ &::after {
+ bottom: 1px;
+ margin-left: -10px;
+ border-top-color: $white;
+ border-bottom-width: 0;
+ content: ' ';
+ }
+}
+
+.right {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-right-color: $popoverArrowBorderColor;
+ border-left-width: 0;
+
+ &::after {
+ bottom: -10px;
+ left: 1px;
+ border-right-color: $white;
+ border-left-width: 0;
+ content: ' ';
+ }
+}
+
+.bottom {
+ top: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+ border-bottom-color: $popoverArrowBorderColor;
+
+ &::after {
+ top: 1px;
+ margin-left: -10px;
+ border-top-width: 0;
+ border-bottom-color: $white;
+ content: ' ';
+ }
+}
+
+.left {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+ border-left-color: $popoverArrowBorderColor;
+
+ &::after {
+ right: 1px;
+ bottom: -10px;
+ border-right-width: 0;
+ border-left-color: $white;
+ content: ' ';
+ }
+}
+
+.title {
+ padding: 10px 20px;
+ border-bottom: 1px solid $popoverTitleBorderColor;
+ background-color: $popoverTitleBackgroundColor;
+ font-size: 16px;
+}
+
+.body {
+ overflow: auto;
+ padding: 10px;
+}
diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js
new file mode 100644
index 000000000..c958fce1b
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.js
@@ -0,0 +1,160 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import { tooltipPositions } from 'Helpers/Props';
+import styles from './Popover.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [tooltipPositions.TOP]: {
+ ...baseTetherOptions,
+ attachment: 'bottom center',
+ targetAttachment: 'top center'
+ },
+
+ [tooltipPositions.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'middle left',
+ targetAttachment: 'middle right'
+ },
+
+ [tooltipPositions.BOTTOM]: {
+ ...baseTetherOptions,
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+ },
+
+ [tooltipPositions.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'middle right',
+ targetAttachment: 'middle left'
+ }
+};
+
+class Popover extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false
+ };
+
+ this._closeTimeout = null;
+ }
+
+ componentWillUnmount() {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ if (isMobileUtil()) {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+ }
+
+ onMouseEnter = () => {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this._closeTimeout = setTimeout(() => {
+ this.setState({ isOpen: false });
+ }, 100);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ anchor,
+ title,
+ body,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+
+
+
+
+ {title}
+
+
+
+ {body}
+
+
+
+ }
+
+ );
+ }
+}
+
+Popover.propTypes = {
+ className: PropTypes.string,
+ anchor: PropTypes.node.isRequired,
+ title: PropTypes.string.isRequired,
+ body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ position: PropTypes.oneOf(tooltipPositions.all)
+};
+
+Popover.defaultProps = {
+ position: tooltipPositions.TOP
+};
+
+export default Popover;
diff --git a/frontend/src/Components/Tooltip/Tooltip.css b/frontend/src/Components/Tooltip/Tooltip.css
new file mode 100644
index 000000000..d1d798e0f
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.css
@@ -0,0 +1,161 @@
+.tether {
+ z-index: 2000;
+}
+
+.tooltipContainer {
+ margin: 10px 15px;
+}
+
+.tooltip {
+ position: relative;
+
+ &.default {
+ background-color: $white;
+ box-shadow: 0 5px 10px $popoverShadowColor;
+ }
+
+ &.inverse {
+ background-color: $themeDarkColor;
+ box-shadow: 0 5px 10px $popoverShadowInverseColor;
+ }
+}
+
+.arrow,
+.arrow::after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-width: 11px;
+ border-style: solid;
+ border-color: transparent;
+}
+
+.arrow::after {
+ border-width: 10px;
+ content: '';
+}
+
+.top {
+ bottom: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-bottom-width: 0;
+
+ &::after {
+ bottom: 1px;
+ margin-left: -10px;
+ border-bottom-width: 0;
+ content: ' ';
+
+ &.default {
+ border-top-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-top-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-top-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-top-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.right {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-left-width: 0;
+
+ &::after {
+ bottom: -10px;
+ left: 1px;
+ border-left-width: 0;
+ content: ' ';
+
+ &.default {
+ border-right-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-right-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-right-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-right-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.bottom {
+ top: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+
+ &::after {
+ top: 1px;
+ margin-left: -10px;
+ border-top-width: 0;
+ content: ' ';
+
+ &.default {
+ border-bottom-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-bottom-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-bottom-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-bottom-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.left {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+
+ &::after {
+ right: 1px;
+ bottom: -10px;
+ border-right-width: 0;
+ content: ' ';
+
+ &.default {
+ border-left-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-left-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-left-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-left-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.body {
+ padding: 5px;
+}
diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js
new file mode 100644
index 000000000..43caf87e8
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.js
@@ -0,0 +1,163 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import { kinds, tooltipPositions } from 'Helpers/Props';
+import styles from './Tooltip.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [tooltipPositions.TOP]: {
+ ...baseTetherOptions,
+ attachment: 'bottom center',
+ targetAttachment: 'top center'
+ },
+
+ [tooltipPositions.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'middle left',
+ targetAttachment: 'middle right'
+ },
+
+ [tooltipPositions.BOTTOM]: {
+ ...baseTetherOptions,
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+ },
+
+ [tooltipPositions.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'middle right',
+ targetAttachment: 'middle left'
+ }
+};
+
+class Tooltip extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false
+ };
+
+ this._closeTimeout = null;
+ }
+
+ componentWillUnmount() {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ if (isMobileUtil()) {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+ }
+
+ onMouseEnter = () => {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this._closeTimeout = setTimeout(() => {
+ this.setState({ isOpen: false });
+ }, 100);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ anchor,
+ tooltip,
+ kind,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+ }
+
+ );
+ }
+}
+
+Tooltip.propTypes = {
+ className: PropTypes.string,
+ anchor: PropTypes.node.isRequired,
+ tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
+ position: PropTypes.oneOf(tooltipPositions.all)
+};
+
+Tooltip.defaultProps = {
+ kind: kinds.DEFAULT,
+ position: tooltipPositions.TOP
+};
+
+export default Tooltip;
diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js
new file mode 100644
index 000000000..109ad86e7
--- /dev/null
+++ b/frontend/src/Components/keyboardShortcuts.js
@@ -0,0 +1,102 @@
+import React, { Component } from 'react';
+import Mousetrap from 'mousetrap';
+import getDisplayName from 'Helpers/getDisplayName';
+
+export const shortcuts = {
+ OPEN_KEYBOARD_SHORTCUTS_MODAL: {
+ key: '?',
+ name: 'Open This Modal'
+ },
+
+ MOVIE_SEARCH_INPUT: {
+ key: 's',
+ name: 'Focus Search Box'
+ },
+
+ SAVE_SETTINGS: {
+ key: 'mod+s',
+ name: 'Save Settings'
+ }
+};
+
+function keyboardShortcuts(WrappedComponent) {
+ class KeyboardShortcuts extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+ this._mousetrapBindings = {};
+ this._mousetrap = new Mousetrap();
+ this._mousetrap.stopCallback = this.stopCallback;
+ }
+
+ componentWillUnmount() {
+ this.unbindAllShortcuts();
+ this._mousetrap = null;
+ }
+
+ //
+ // Control
+
+ bindShortcut = (key, callback, options = {}) => {
+ this._mousetrap.bind(key, callback);
+ this._mousetrapBindings[key] = options;
+ }
+
+ unbindShortcut = (key) => {
+ delete this._mousetrapBindings[key];
+ this._mousetrap.unbind(key);
+ }
+
+ unbindAllShortcuts = () => {
+ const keys = Object.keys(this._mousetrapBindings);
+
+ if (!keys.length) {
+ return;
+ }
+
+ keys.forEach((binding) => {
+ this._mousetrap.unbind(binding);
+ });
+
+ this._mousetrapBindings = {};
+ }
+
+ stopCallback = (event, element, combo) => {
+ const binding = this._mousetrapBindings[combo];
+
+ if (!binding || binding.isGlobal) {
+ return false;
+ }
+
+ return (
+ element.tagName === 'INPUT' ||
+ element.tagName === 'SELECT' ||
+ element.tagName === 'TEXTAREA' ||
+ (element.contentEditable && element.contentEditable === 'true')
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+ }
+
+ KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`;
+ KeyboardShortcuts.WrappedComponent = WrappedComponent;
+
+ return KeyboardShortcuts;
+}
+
+export default keyboardShortcuts;
diff --git a/frontend/src/Components/withCurrentPage.js b/frontend/src/Components/withCurrentPage.js
new file mode 100644
index 000000000..5e6d9ccf4
--- /dev/null
+++ b/frontend/src/Components/withCurrentPage.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+function withCurrentPage(WrappedComponent) {
+ function CurrentPage(props) {
+ const {
+ history
+ } = props;
+
+ return (
+
+ );
+ }
+
+ CurrentPage.propTypes = {
+ history: PropTypes.object.isRequired
+ };
+
+ return CurrentPage;
+}
+
+export default withCurrentPage;
diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js
new file mode 100644
index 000000000..110da9ab2
--- /dev/null
+++ b/frontend/src/Components/withScrollPosition.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import scrollPositions from 'Store/scrollPositions';
+
+function withScrollPosition(WrappedComponent, scrollPositionKey) {
+ function ScrollPosition(props) {
+ const {
+ history
+ } = props;
+
+ const scrollTop = history.action === 'POP' ?
+ scrollPositions[scrollPositionKey] :
+ 0;
+
+ return (
+
+ );
+ }
+
+ ScrollPosition.propTypes = {
+ history: PropTypes.object.isRequired
+ };
+
+ return ScrollPosition;
+}
+
+export default withScrollPosition;
diff --git a/frontend/src/Content/Fonts/Roboto-Light.ttf b/frontend/src/Content/Fonts/Roboto-Light.ttf
new file mode 100644
index 000000000..94c6bcc67
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.ttf differ
diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff b/frontend/src/Content/Fonts/Roboto-Light.woff
new file mode 100644
index 000000000..ec6bf5749
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff differ
diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff2 b/frontend/src/Content/Fonts/Roboto-Light.woff2
new file mode 100644
index 000000000..288201788
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff2 differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.ttf b/frontend/src/Content/Fonts/Roboto-Regular.ttf
new file mode 100644
index 000000000..8c082c8de
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.ttf differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff b/frontend/src/Content/Fonts/Roboto-Regular.woff
new file mode 100644
index 000000000..464d20623
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff2 b/frontend/src/Content/Fonts/Roboto-Regular.woff2
new file mode 100644
index 000000000..f96619675
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff2 differ
diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot
new file mode 100644
index 000000000..7a03fb512
Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot differ
diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
new file mode 100644
index 000000000..fdd309d71
Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf differ
diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff
new file mode 100644
index 000000000..0289699c0
Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff differ
diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css
new file mode 100644
index 000000000..3db8ae6b0
--- /dev/null
+++ b/frontend/src/Content/Fonts/fonts.css
@@ -0,0 +1,38 @@
+@font-face {
+ font-weight: 300;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Light.woff2?v=1.1.0') format('woff2'), url('Roboto-Light.woff?v=1.2.0') format('woff'), url('Roboto-Light.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Regular.woff2?v=1.2.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: normal;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Ubuntu Mono';
+ src: url('UbuntuMono-Regular.eot?#iefix') format('embedded-opentype'), url('UbuntuMono-Regular.woff') format('woff'), url('UbuntuMono-Regular.ttf') format('truetype');
+}
+
+/*
+ * text-security-disc
+ */
+
+@font-face {
+ font-weight: normal;
+ font-style: normal;
+ font-family: 'text-security-disc';
+ src: url('text-security-disc.woff') format('woff'), url('text-security-disc.ttf') format('truetype');
+}
diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf
new file mode 100644
index 000000000..86038dba8
Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.ttf differ
diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff
new file mode 100644
index 000000000..bc4cc324b
Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.woff differ
diff --git a/frontend/src/Content/Images/404.png b/frontend/src/Content/Images/404.png
new file mode 100644
index 000000000..deeb83f8f
Binary files /dev/null and b/frontend/src/Content/Images/404.png differ
diff --git a/frontend/src/Content/Images/Icons/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/android-chrome-192x192.png
new file mode 100644
index 000000000..242032ea1
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..a9a9e924d
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..1a3612672
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..40d6cc24c
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-32x32.png b/frontend/src/Content/Images/Icons/favicon-32x32.png
new file mode 100644
index 000000000..a05042842
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico
new file mode 100644
index 000000000..73ba8d1ba
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json
new file mode 100644
index 000000000..d14732f60
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/manifest.json
@@ -0,0 +1,18 @@
+{
+ "name": "",
+ "icons": [
+ {
+ "src": "/Content/Images/Icons/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/Content/Images/Icons/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#3a3f51",
+ "background_color": "#3a3f51",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/frontend/src/Content/Images/Icons/mstile-144x144.png b/frontend/src/Content/Images/Icons/mstile-144x144.png
new file mode 100644
index 000000000..51b02be34
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..261955fcb
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..6d8e7afe3
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..2fe09d2d4
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..612b0d293
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..2356a9a27
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg
@@ -0,0 +1,29 @@
+
+
+
+
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+
+
+
+
+
+
+
diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png
new file mode 100644
index 000000000..9b1ae7746
Binary files /dev/null and b/frontend/src/Content/Images/error.png differ
diff --git a/frontend/src/Content/Images/logo-full.png b/frontend/src/Content/Images/logo-full.png
new file mode 100644
index 000000000..ce2a8c3a1
Binary files /dev/null and b/frontend/src/Content/Images/logo-full.png differ
diff --git a/frontend/src/Content/Images/logo.png b/frontend/src/Content/Images/logo.png
new file mode 100644
index 000000000..ec87857a8
Binary files /dev/null and b/frontend/src/Content/Images/logo.png differ
diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg
new file mode 100644
index 000000000..b0a721893
--- /dev/null
+++ b/frontend/src/Content/Images/logo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png
new file mode 100644
index 000000000..6a88711c3
Binary files /dev/null and b/frontend/src/Content/Images/poster-dark.png differ
diff --git a/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js
new file mode 100644
index 000000000..11cca7d1b
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js
@@ -0,0 +1,11 @@
+import PropTypes from 'prop-types';
+
+function createRouteMatchShape(props) {
+ return PropTypes.shape({
+ params: PropTypes.shape({
+ ...props
+ }).isRequired
+ });
+}
+
+export default createRouteMatchShape;
diff --git a/frontend/src/Helpers/Props/Shapes/locationShape.js b/frontend/src/Helpers/Props/Shapes/locationShape.js
new file mode 100644
index 000000000..80b53eb44
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/locationShape.js
@@ -0,0 +1,11 @@
+import PropTypes from 'prop-types';
+
+const locationShape = PropTypes.shape({
+ pathname: PropTypes.string.isRequired,
+ search: PropTypes.string.isRequired,
+ state: PropTypes.object,
+ action: PropTypes.string,
+ key: PropTypes.string
+});
+
+export default locationShape;
diff --git a/frontend/src/Helpers/Props/Shapes/settingShape.js b/frontend/src/Helpers/Props/Shapes/settingShape.js
new file mode 100644
index 000000000..cd672de27
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/settingShape.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+
+const settingShape = {
+ value: PropTypes.oneOf([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ warnings: PropTypes.arrayOf(PropTypes.string).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export const arraySettingShape = {
+ ...settingShape,
+ value: PropTypes.array.isRequired
+};
+
+export const boolSettingShape = {
+ ...settingShape,
+ value: PropTypes.bool.isRequired
+};
+
+export const numberSettingShape = {
+ ...settingShape,
+ value: PropTypes.number.isRequired
+};
+
+export const stringSettingShape = {
+ ...settingShape,
+ value: PropTypes.string
+};
+
+export const tagSettingShape = {
+ ...settingShape,
+ value: PropTypes.arrayOf(PropTypes.number).isRequired
+};
+
+export default settingShape;
diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.js
new file mode 100644
index 000000000..f381959c6
--- /dev/null
+++ b/frontend/src/Helpers/Props/align.js
@@ -0,0 +1,5 @@
+export const LEFT = 'left';
+export const CENTER = 'center';
+export const RIGHT = 'right';
+
+export const all = [LEFT, CENTER, RIGHT];
diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js
new file mode 100644
index 000000000..72722ab63
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterBuilderTypes.js
@@ -0,0 +1,50 @@
+import * as filterTypes from './filterTypes';
+
+export const ARRAY = 'array';
+export const DATE = 'date';
+export const EXACT = 'exact';
+export const NUMBER = 'number';
+export const STRING = 'string';
+
+export const all = [
+ ARRAY,
+ DATE,
+ EXACT,
+ NUMBER,
+ STRING
+];
+
+export const possibleFilterTypes = {
+ [ARRAY]: [
+ { key: filterTypes.CONTAINS, value: 'contains' },
+ { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }
+ ],
+
+ [DATE]: [
+ { key: filterTypes.LESS_THAN, value: 'is before' },
+ { key: filterTypes.GREATER_THAN, value: 'is after' },
+ { key: filterTypes.IN_LAST, value: 'in the last' },
+ { key: filterTypes.IN_NEXT, value: 'in the next' }
+ ],
+
+ [EXACT]: [
+ { key: filterTypes.EQUAL, value: 'is' },
+ { key: filterTypes.NOT_EQUAL, value: 'is not' }
+ ],
+
+ [NUMBER]: [
+ { key: filterTypes.EQUAL, value: 'equal' },
+ { key: filterTypes.GREATER_THAN, value: 'greater than' },
+ { key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' },
+ { key: filterTypes.LESS_THAN, value: 'less than' },
+ { key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' },
+ { key: filterTypes.NOT_EQUAL, value: 'not equal' }
+ ],
+
+ [STRING]: [
+ { key: filterTypes.CONTAINS, value: 'contains' },
+ { key: filterTypes.NOT_CONTAINS, value: 'does not contain' },
+ { key: filterTypes.EQUAL, value: 'equal' },
+ { key: filterTypes.NOT_EQUAL, value: 'not equal' }
+ ]
+};
diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
new file mode 100644
index 000000000..b2e9cf1fc
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
@@ -0,0 +1,10 @@
+export const BOOL = 'bool';
+export const BYTES = 'bytes';
+export const DATE = 'date';
+export const DEFAULT = 'default';
+export const INDEXER = 'indexer';
+export const PROTOCOL = 'protocol';
+export const QUALITY = 'quality';
+export const QUALITY_PROFILE = 'qualityProfile';
+export const SERIES_STATUS = 'seriesStatus';
+export const TAG = 'tag';
diff --git a/frontend/src/Helpers/Props/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js
new file mode 100644
index 000000000..a3ea11956
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterTypePredicates.js
@@ -0,0 +1,45 @@
+import * as filterTypes from './filterTypes';
+
+const filterTypePredicates = {
+ [filterTypes.CONTAINS]: function(itemValue, filterValue) {
+ if (Array.isArray(itemValue)) {
+ return itemValue.some((v) => v === filterValue);
+ }
+
+ return itemValue.toLowerCase().contains(filterValue.toLowerCase());
+ },
+
+ [filterTypes.EQUAL]: function(itemValue, filterValue) {
+ return itemValue === filterValue;
+ },
+
+ [filterTypes.GREATER_THAN]: function(itemValue, filterValue) {
+ return itemValue > filterValue;
+ },
+
+ [filterTypes.GREATER_THAN_OR_EQUAL]: function(itemValue, filterValue) {
+ return itemValue >= filterValue;
+ },
+
+ [filterTypes.LESS_THAN]: function(itemValue, filterValue) {
+ return itemValue < filterValue;
+ },
+
+ [filterTypes.LESS_THAN_OR_EQUAL]: function(itemValue, filterValue) {
+ return itemValue <= filterValue;
+ },
+
+ [filterTypes.NOT_CONTAINS]: function(itemValue, filterValue) {
+ if (Array.isArray(itemValue)) {
+ return !itemValue.some((v) => v === filterValue);
+ }
+
+ return !itemValue.toLowerCase().contains(filterValue.toLowerCase());
+ },
+
+ [filterTypes.NOT_EQUAL]: function(itemValue, filterValue) {
+ return itemValue !== filterValue;
+ }
+};
+
+export default filterTypePredicates;
diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js
new file mode 100644
index 000000000..77809b8ce
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterTypes.js
@@ -0,0 +1,21 @@
+export const CONTAINS = 'contains';
+export const EQUAL = 'equal';
+export const GREATER_THAN = 'greaterThan';
+export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual';
+export const IN_LAST = 'inLast';
+export const IN_NEXT = 'inNext';
+export const LESS_THAN = 'lessThan';
+export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual';
+export const NOT_CONTAINS = 'notContains';
+export const NOT_EQUAL = 'notEqual';
+
+export const all = [
+ CONTAINS,
+ EQUAL,
+ GREATER_THAN,
+ GREATER_THAN_OR_EQUAL,
+ LESS_THAN,
+ LESS_THAN_OR_EQUAL,
+ NOT_CONTAINS,
+ NOT_EQUAL
+];
diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js
new file mode 100644
index 000000000..1125f661b
--- /dev/null
+++ b/frontend/src/Helpers/Props/icons.js
@@ -0,0 +1,205 @@
+//
+// Regular
+
+import {
+ faBookmark as farBookmark,
+ faCalendar as farCalendar,
+ faCircle as farCircle,
+ faClock as farClock,
+ faClone as farClone,
+ faDotCircle as farDotCircle,
+ faFile as farFile,
+ faFileArchive as farFileArchive,
+ faFileVideo as farFileVideo,
+ faFolder as farFolder,
+ faObjectGroup as farObjectGroup,
+ faHdd as farHdd,
+ faKeyboard as farKeyboard,
+ faObjectUngroup as farObjectUngroup
+} from '@fortawesome/free-regular-svg-icons';
+
+//
+// Solid
+
+import {
+ faArrowCircleLeft as fasArrowCircleLeft,
+ faArrowCircleRight as fasArrowCircleRight,
+ faBackward as fasBackward,
+ faBars as fasBars,
+ faBolt as fasBolt,
+ faBookmark as fasBookmark,
+ faBookReader as fasBookReader,
+ faBug as fasBug,
+ faBroadcastTower as fasBroadcastTower,
+ faCalendarAlt as fasCalendarAlt,
+ faCaretDown as fasCaretDown,
+ faCheck as fasCheck,
+ faChevronCircleDown as fasChevronCircleDown,
+ faChevronCircleRight as fasChevronCircleRight,
+ faChevronCircleUp as fasChevronCircleUp,
+ faCheckCircle as fasCheckCircle,
+ faCircle as fasCircle,
+ faCloudDownloadAlt as fasCloudDownloadAlt,
+ faCloud as fasCloud,
+ faCog as fasCog,
+ faCogs as fasCogs,
+ faCopy as fasCopy,
+ faDesktop as fasDesktop,
+ faDownload as fasDownload,
+ faEllipsisH as fasEllipsisH,
+ faExclamationCircle as fasExclamationCircle,
+ faExclamationTriangle as fasExclamationTriangle,
+ faExternalLinkAlt as fasExternalLinkAlt,
+ faEye as fasEye,
+ faFastBackward as fasFastBackward,
+ faFastForward as fasFastForward,
+ faFileInvoice as farFileInvoice,
+ faFilm as fasFilm,
+ faFilter as fasFilter,
+ faFolderOpen as fasFolderOpen,
+ faForward as fasForward,
+ faHeart as fasHeart,
+ faHistory as fasHistory,
+ faHome as fasHome,
+ faInfoCircle as fasInfoCircle,
+ faLaptop as fasLaptop,
+ faLevelUpAlt as fasLevelUpAlt,
+ faMedkit as fasMedkit,
+ faMinus as fasMinus,
+ faPause as fasPause,
+ faPlay as fasPlay,
+ faPlus as fasPlus,
+ faPowerOff as fasPowerOff,
+ faQuestion as fasQuestion,
+ faQuestionCircle as fasQuestionCircle,
+ faRedoAlt as fasRedoAlt,
+ faRetweet as fasRetweet,
+ faRss as fasRss,
+ faRocket as fasRocket,
+ faSave as fasSave,
+ faSearch as fasSearch,
+ faSignOutAlt as fasSignOutAlt,
+ faSitemap as fasSitemap,
+ faSpinner as fasSpinner,
+ faSort as fasSort,
+ faSortDown as fasSortDown,
+ faSortUp as fasSortUp,
+ faStop as fasStop,
+ faSync as fasSync,
+ faTags as fasTags,
+ faTable as fasTable,
+ faTh as fasTh,
+ faThList as fasThList,
+ faTrashAlt as fasTrashAlt,
+ faTimes as fasTimes,
+ faTimesCircle as fasTimesCircle,
+ faUser as fasUser,
+ faUserPlus as fasUserPlus,
+ faVial as fasVial,
+ faWrench as fasWrench
+} from '@fortawesome/free-solid-svg-icons';
+
+//
+// Icons
+
+export const ACTIONS = fasBolt;
+export const ACTIVITY = farClock;
+export const ADD = fasPlus;
+export const ALTERNATE_TITLES = farClone;
+export const ADVANCED_SETTINGS = fasCog;
+export const ARROW_LEFT = fasArrowCircleLeft;
+export const ARROW_RIGHT = fasArrowCircleRight;
+export const BACKUP = farFileArchive;
+export const BUG = fasBug;
+export const CALENDAR = fasCalendarAlt;
+export const CALENDAR_O = farCalendar;
+export const CARET_DOWN = fasCaretDown;
+export const CHECK = fasCheck;
+export const CHECK_INDETERMINATE = fasMinus;
+export const CHECK_CIRCLE = fasCheckCircle;
+export const CIRCLE = fasCircle;
+export const CIRCLE_OUTLINE = farCircle;
+export const CLEAR = fasTrashAlt;
+export const CLIPBOARD = fasCopy;
+export const CLOSE = fasTimes;
+export const CLONE = farClone;
+export const COLLAPSE = fasChevronCircleUp;
+export const COMPUTER = fasDesktop;
+export const DANGER = fasExclamationCircle;
+export const DELETE = fasTrashAlt;
+export const DOWNLOAD = fasDownload;
+export const DOWNLOADED = fasDownload;
+export const DOWNLOADING = fasCloudDownloadAlt;
+export const DRIVE = farHdd;
+export const EDIT = fasWrench;
+export const MOVIE_FILE = farFileVideo;
+export const EXPAND = fasChevronCircleDown;
+export const EXPAND_INDETERMINATE = fasChevronCircleRight;
+export const EXTERNAL_LINK = fasExternalLinkAlt;
+export const FATAL = fasTimesCircle;
+export const FILE = farFile;
+export const FILTER = fasFilter;
+export const FOLDER = farFolder;
+export const FOLDER_OPEN = fasFolderOpen;
+export const GROUP = farObjectGroup;
+export const HEALTH = fasMedkit;
+export const HEART = fasHeart;
+export const HISTORY = fasHistory;
+export const HOUSEKEEPING = fasHome;
+export const INFO = fasInfoCircle;
+export const INTERACTIVE = fasUser;
+export const KEYBOARD = farKeyboard;
+export const LOGOUT = fasSignOutAlt;
+export const MEDIA_INFO = farFileInvoice;
+export const MISSING = fasExclamationTriangle;
+export const MONITORED = fasBookmark;
+export const NETWORK = fasBroadcastTower;
+export const NAVBAR_COLLAPSE = fasBars;
+export const NOT_AIRED = farClock;
+export const ORGANIZE = fasSitemap;
+export const OVERFLOW = fasEllipsisH;
+export const OVERVIEW = fasThList;
+export const PAGE_FIRST = fasFastBackward;
+export const PAGE_PREVIOUS = fasBackward;
+export const PAGE_NEXT = fasForward;
+export const PAGE_LAST = fasFastForward;
+export const PARENT = fasLevelUpAlt;
+export const PAUSED = fasPause;
+export const PENDING = farClock;
+export const PROFILE = fasUser;
+export const POSTER = fasTh;
+export const QUEUED = fasCloud;
+export const QUICK = fasRocket;
+export const REFRESH = fasSync;
+export const REMOVE = fasTimes;
+export const RESTART = fasRedoAlt;
+export const RESTORE = fasHistory;
+export const REORDER = fasBars;
+export const RSS = fasRss;
+export const SAVE = fasSave;
+export const SCHEDULED = farClock;
+export const SCORE = fasUserPlus;
+export const SEARCH = fasSearch;
+export const SERIES_CONTINUING = fasPlay;
+export const SERIES_ENDED = fasStop;
+export const SETTINGS = fasCogs;
+export const SHUTDOWN = fasPowerOff;
+export const SORT = fasSort;
+export const SORT_ASCENDING = fasSortUp;
+export const SORT_DESCENDING = fasSortDown;
+export const SPINNER = fasSpinner;
+export const STUDIO = fasFilm;
+export const SUBTRACT = fasMinus;
+export const SYSTEM = fasLaptop;
+export const TABLE = fasTable;
+export const TAGS = fasTags;
+export const TBA = fasQuestionCircle;
+export const TEST = fasVial;
+export const UNGROUP = farObjectUngroup;
+export const UNKNOWN = fasQuestion;
+export const UNMONITORED = farBookmark;
+export const UPDATE = fasRetweet;
+export const UNSAVED_SETTING = farDotCircle;
+export const VIEW = fasEye;
+export const WARNING = fasExclamationTriangle;
+export const WIKI = fasBookReader;
diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js
new file mode 100644
index 000000000..3f4f94f6f
--- /dev/null
+++ b/frontend/src/Helpers/Props/index.js
@@ -0,0 +1,29 @@
+import * as align from './align';
+import * as inputTypes from './inputTypes';
+import * as filterBuilderTypes from './filterBuilderTypes';
+import * as filterBuilderValueTypes from './filterBuilderValueTypes';
+import filterTypePredicates from './filterTypePredicates';
+import * as filterTypes from './filterTypes';
+import * as icons from './icons';
+import * as kinds from './kinds';
+import * as messageTypes from './messageTypes';
+import * as sizes from './sizes';
+import * as scrollDirections from './scrollDirections';
+import * as sortDirections from './sortDirections';
+import * as tooltipPositions from './tooltipPositions';
+
+export {
+ align,
+ inputTypes,
+ filterBuilderTypes,
+ filterBuilderValueTypes,
+ filterTypePredicates,
+ filterTypes,
+ icons,
+ kinds,
+ messageTypes,
+ sizes,
+ scrollDirections,
+ sortDirections,
+ tooltipPositions
+};
diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js
new file mode 100644
index 000000000..92a7d2d39
--- /dev/null
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -0,0 +1,33 @@
+export const AUTO_COMPLETE = 'autoComplete';
+export const CAPTCHA = 'captcha';
+export const CHECK = 'check';
+export const DEVICE = 'device';
+export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect';
+export const NUMBER = 'number';
+export const OAUTH = 'oauth';
+export const PASSWORD = 'password';
+export const PATH = 'path';
+export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
+export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
+export const SELECT = 'select';
+export const TAG = 'tag';
+export const TEXT = 'text';
+export const TEXT_TAG = 'textTag';
+
+export const all = [
+ AUTO_COMPLETE,
+ CAPTCHA,
+ CHECK,
+ DEVICE,
+ MOVIE_MONITORED_SELECT,
+ NUMBER,
+ OAUTH,
+ PASSWORD,
+ PATH,
+ QUALITY_PROFILE_SELECT,
+ ROOT_FOLDER_SELECT,
+ SELECT,
+ TAG,
+ TEXT,
+ TEXT_TAG
+];
diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js
new file mode 100644
index 000000000..fd2c17f7b
--- /dev/null
+++ b/frontend/src/Helpers/Props/kinds.js
@@ -0,0 +1,23 @@
+export const DANGER = 'danger';
+export const DEFAULT = 'default';
+export const DISABLED = 'disabled';
+export const INFO = 'info';
+export const INVERSE = 'inverse';
+export const PINK = 'pink';
+export const PRIMARY = 'primary';
+export const PURPLE = 'purple';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ DANGER,
+ DEFAULT,
+ DISABLED,
+ INFO,
+ INVERSE,
+ PINK,
+ PRIMARY,
+ PURPLE,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js
new file mode 100644
index 000000000..997354f9d
--- /dev/null
+++ b/frontend/src/Helpers/Props/messageTypes.js
@@ -0,0 +1,11 @@
+export const ERROR = 'error';
+export const INFO = 'info';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ ERROR,
+ INFO,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js
new file mode 100644
index 000000000..5e4a4fe08
--- /dev/null
+++ b/frontend/src/Helpers/Props/scrollDirections.js
@@ -0,0 +1,5 @@
+export const NONE = 'none';
+export const HORIZONTAL = 'horizontal';
+export const VERTICAL = 'vertical';
+
+export const all = [NONE, HORIZONTAL, VERTICAL];
diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js
new file mode 100644
index 000000000..d7f85df5e
--- /dev/null
+++ b/frontend/src/Helpers/Props/sizes.js
@@ -0,0 +1,7 @@
+export const EXTRA_SMALL = 'extraSmall';
+export const SMALL = 'small';
+export const MEDIUM = 'medium';
+export const LARGE = 'large';
+export const EXTRA_LARGE = 'extraLarge';
+
+export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE];
diff --git a/frontend/src/Helpers/Props/sortDirections.js b/frontend/src/Helpers/Props/sortDirections.js
new file mode 100644
index 000000000..ff3b17bb6
--- /dev/null
+++ b/frontend/src/Helpers/Props/sortDirections.js
@@ -0,0 +1,4 @@
+export const ASCENDING = 'ascending';
+export const DESCENDING = 'descending';
+
+export const all = [ASCENDING, DESCENDING];
diff --git a/frontend/src/Helpers/Props/tooltipPositions.js b/frontend/src/Helpers/Props/tooltipPositions.js
new file mode 100644
index 000000000..bca3c4ed4
--- /dev/null
+++ b/frontend/src/Helpers/Props/tooltipPositions.js
@@ -0,0 +1,11 @@
+export const TOP = 'top';
+export const RIGHT = 'right';
+export const BOTTOM = 'bottom';
+export const LEFT = 'left';
+
+export const all = [
+ TOP,
+ RIGHT,
+ BOTTOM,
+ LEFT
+];
diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js
new file mode 100644
index 000000000..ed6ba080d
--- /dev/null
+++ b/frontend/src/Helpers/dragTypes.js
@@ -0,0 +1,3 @@
+export const QUALITY_PROFILE_ITEM = 'qualityProfileItem';
+export const DELAY_PROFILE = 'delayProfile';
+export const TABLE_COLUMN = 'tableColumn';
diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js
new file mode 100644
index 000000000..1c10b2f0e
--- /dev/null
+++ b/frontend/src/Helpers/elementChildren.js
@@ -0,0 +1,149 @@
+// https://github.com/react-bootstrap/react-element-children
+
+import React from 'react';
+
+/**
+ * Iterates through children that are typically specified as `props.children`,
+ * but only maps over children that are "valid components".
+ *
+ * The mapFunction provided index will be normalised to the components mapped,
+ * so an invalid component would not increase the index.
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for func.
+ * @return {object} Object containing the ordered map of results.
+ */
+export function map(children, func, context) {
+ let index = 0;
+
+ return React.Children.map(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return child;
+ }
+
+ return func.call(context, child, index++);
+ });
+}
+
+/**
+ * Iterates through children that are "valid components".
+ *
+ * The provided forEachFunc(child, index) will be called for each
+ * leaf child with the index reflecting the position relative to "valid components".
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for context.
+ */
+export function forEach(children, func, context) {
+ let index = 0;
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ func.call(context, child, index++);
+ });
+}
+
+/**
+ * Count the number of "valid components" in the Children container.
+ *
+ * @param {?*} children Children tree container.
+ * @returns {number}
+ */
+export function count(children) {
+ let result = 0;
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ ++result;
+ });
+
+ return result;
+}
+
+/**
+ * Finds children that are typically specified as `props.children`,
+ * but only iterates over children that are "valid components".
+ *
+ * The provided forEachFunc(child, index) will be called for each
+ * leaf child with the index reflecting the position relative to "valid components".
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for func.
+ * @returns {array} of children that meet the func return statement
+ */
+export function filter(children, func, context) {
+ const result = [];
+
+ forEach(children, (child, index) => {
+ if (func.call(context, child, index)) {
+ result.push(child);
+ }
+ });
+
+ return result;
+}
+
+export function find(children, func, context) {
+ let result = null;
+
+ forEach(children, (child, index) => {
+ if (result) {
+ return;
+ }
+ if (func.call(context, child, index)) {
+ result = child;
+ }
+ });
+
+ return result;
+}
+
+export function every(children, func, context) {
+ let result = true;
+
+ forEach(children, (child, index) => {
+ if (!result) {
+ return;
+ }
+ if (!func.call(context, child, index)) {
+ result = false;
+ }
+ });
+
+ return result;
+}
+
+export function some(children, func, context) {
+ let result = false;
+
+ forEach(children, (child, index) => {
+ if (result) {
+ return;
+ }
+
+ if (func.call(context, child, index)) {
+ result = true;
+ }
+ });
+
+ return result;
+}
+
+export function toArray(children) {
+ const result = [];
+
+ forEach(children, (child) => {
+ result.push(child);
+ });
+
+ return result;
+}
diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js
new file mode 100644
index 000000000..512702c87
--- /dev/null
+++ b/frontend/src/Helpers/getDisplayName.js
@@ -0,0 +1,3 @@
+export default function getDisplayName(Component) {
+ return Component.displayName || Component.name || 'Component';
+}
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css
new file mode 100644
index 000000000..86418a2dd
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css
@@ -0,0 +1,24 @@
+.recentFoldersContainer {
+ margin-top: 15px;
+}
+
+.buttonsContainer {
+ margin-top: 30px;
+}
+
+.buttonContainer {
+ display: flex;
+ justify-content: center;
+
+ margin-top: 10px;
+}
+
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ width: 300px;
+}
+
+.buttonIcon {
+ margin-right: 5px;
+}
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js
new file mode 100644
index 000000000..78df1f53e
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js
@@ -0,0 +1,168 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import PathInputConnector from 'Components/Form/PathInputConnector';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import RecentFolderRow from './RecentFolderRow';
+import styles from './InteractiveImportSelectFolderModalContent.css';
+
+const recentFoldersColumns = [
+ {
+ name: 'folder',
+ label: 'Folder'
+ },
+ {
+ name: 'lastUsed',
+ label: 'Last Used'
+ },
+ {
+ name: 'actions',
+ label: ''
+ }
+];
+
+class InteractiveImportSelectFolderModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ folder: ''
+ };
+ }
+
+ //
+ // Listeners
+
+ onPathChange = ({ value }) => {
+ this.setState({ folder: value });
+ }
+
+ onRecentPathPress = (folder) => {
+ this.setState({ folder });
+ }
+
+ onQuickImportPress = () => {
+ this.props.onQuickImportPress(this.state.folder);
+ }
+
+ onInteractiveImportPress = () => {
+ this.props.onInteractiveImportPress(this.state.folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ recentFolders,
+ onRemoveRecentFolderPress,
+ onModalClose
+ } = this.props;
+
+ const folder = this.state.folder;
+
+ return (
+
+
+ Manual Import - Select Folder
+
+
+
+
+
+ {
+ !!recentFolders.length &&
+
+
+
+ {
+ recentFolders.map((recentFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+
+
+
+ Quick Import
+
+
+
+
+
+
+
+ Interactive Import
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+InteractiveImportSelectFolderModalContent.propTypes = {
+ recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onQuickImportPress: PropTypes.func.isRequired,
+ onInteractiveImportPress: PropTypes.func.isRequired,
+ onRemoveRecentFolderPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default InteractiveImportSelectFolderModalContent;
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js
new file mode 100644
index 000000000..92d729800
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.interactiveImport.recentFolders,
+ (recentFolders) => {
+ return {
+ recentFolders
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ addRecentFolder,
+ removeRecentFolder,
+ executeCommand
+};
+
+class InteractiveImportSelectFolderModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onQuickImportPress = (folder) => {
+ this.props.addRecentFolder({ folder });
+
+ this.props.executeCommand({
+ name: commandNames.DOWNLOADED_EPSIODES_SCAN,
+ path: folder
+ });
+
+ this.props.onModalClose();
+ }
+
+ onInteractiveImportPress = (folder) => {
+ this.props.addRecentFolder({ folder });
+ this.props.onFolderSelect(folder);
+ }
+
+ onRemoveRecentFolderPress = (folder) => {
+ this.props.removeRecentFolder({ folder });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (this.path) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+InteractiveImportSelectFolderModalContentConnector.propTypes = {
+ path: PropTypes.string,
+ onFolderSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ addRecentFolder: PropTypes.func.isRequired,
+ removeRecentFolder: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.css b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css
new file mode 100644
index 000000000..edb55b075
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css
@@ -0,0 +1,5 @@
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 40px;
+}
diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
new file mode 100644
index 000000000..403bce33d
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import styles from './RecentFolderRow.css';
+
+class RecentFolderRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.folder);
+ }
+
+ onRemovePress = (event) => {
+ event.stopPropagation();
+
+ const {
+ folder,
+ onRemoveRecentFolderPress
+ } = this.props;
+
+ onRemoveRecentFolderPress(folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ folder,
+ lastUsed
+ } = this.props;
+
+ return (
+
+ {folder}
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RecentFolderRow.propTypes = {
+ folder: PropTypes.string.isRequired,
+ lastUsed: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired,
+ onRemoveRecentFolderPress: PropTypes.func.isRequired
+};
+
+export default RecentFolderRow;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
new file mode 100644
index 000000000..5bad6c050
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
@@ -0,0 +1,73 @@
+.filterContainer {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 10px;
+}
+
+.filterText {
+ margin-left: 5px;
+}
+
+.footer {
+ composes: modalFooter from 'Components/Modal/ModalFooter.css';
+
+ justify-content: space-between;
+ padding: 15px;
+}
+
+.leftButtons,
+.centerButtons,
+.rightButtons {
+ display: flex;
+ flex: 1 0 33%;
+ flex-wrap: wrap;
+}
+
+.centerButtons {
+ justify-content: center;
+}
+
+.rightButtons {
+ justify-content: flex-end;
+}
+
+.importMode {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ width: auto;
+}
+
+.errorMessage {
+ color: $dangerColor;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .footer {
+ .leftButtons,
+ .centerButtons,
+ .rightButtons {
+ flex-direction: column;
+ }
+
+ .leftButtons {
+ align-items: flex-start;
+ }
+
+ .centerButtons {
+ align-items: center;
+ }
+
+ .rightButtons {
+ align-items: flex-end;
+ }
+
+ a,
+ button {
+ margin-left: 0;
+
+ &:first-child {
+ margin-bottom: 5px;
+ }
+ }
+ }
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js
new file mode 100644
index 000000000..fe8ce2ecc
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js
@@ -0,0 +1,361 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectInput from 'Components/Form/SelectInput';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
+import 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: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, {
+ name: icons.DANGER,
+ kind: kinds.DANGER
+ }),
+ isVisible: true
+ }
+];
+
+const filterExistingFilesOptions = {
+ ALL: 'all',
+ NEW: 'new'
+};
+
+class InteractiveImportModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ invalidRowsSelected: [],
+ isSelectSeriesModalOpen: false
+ };
+ }
+
+ //
+ // 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.props.importMode);
+ }
+
+ onFilterExistingFilesChange = (value) => {
+ this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL);
+ }
+
+ onImportModeChange = ({ value }) => {
+ this.props.onImportModeChange(value);
+ }
+
+ onSelectSeriesPress = () => {
+ this.setState({ isSelectSeriesModalOpen: true });
+ }
+
+ onSelectSeriesModalClose = () => {
+ this.setState({ isSelectSeriesModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ downloadId,
+ allowSeriesChange,
+ showFilterExistingFiles,
+ showImportMode,
+ filterExistingFiles,
+ title,
+ folder,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ importMode,
+ interactiveImportErrorMessage,
+ onSortPress,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ invalidRowsSelected,
+ isSelectSeriesModalOpen
+ } = this.state;
+
+ const selectedIds = this.getSelectedIds();
+ const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
+
+ const importModeOptions = [
+ { key: 'move', value: 'Move Files' },
+ { key: 'copy', value: 'Copy Files' }
+ ];
+
+ return (
+
+
+ Manual Import - {title || folder}
+
+
+
+ {
+ showFilterExistingFiles &&
+
+
+
+
+
+
+ {
+ filterExistingFiles ? 'Unmapped Files Only' : 'All Files'
+ }
+
+
+
+
+
+ All Files
+
+
+
+ Unmapped Files Only
+
+
+
+
+ }
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+ {
+ isPopulated && !!items.length && !isFetching && !isFetching &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length && !isFetching &&
+ 'No video files were found in the selected folder'
+ }
+
+
+
+
+ {
+ !downloadId && showImportMode &&
+
+ }
+
+
+
+ {
+ allowSeriesChange &&
+
+ Select Series
+
+ }
+
+
+
+
+ Cancel
+
+
+ {
+ interactiveImportErrorMessage &&
+ {interactiveImportErrorMessage}
+ }
+
+
+ Import
+
+
+
+
+
+
+ );
+ }
+}
+
+InteractiveImportModalContent.propTypes = {
+ downloadId: PropTypes.string,
+ allowSeriesChange: PropTypes.bool.isRequired,
+ showImportMode: PropTypes.bool.isRequired,
+ showFilterExistingFiles: PropTypes.bool.isRequired,
+ filterExistingFiles: PropTypes.bool.isRequired,
+ importMode: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ folder: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ interactiveImportErrorMessage: PropTypes.string,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterExistingFilesChange: PropTypes.func.isRequired,
+ onImportModeChange: PropTypes.func.isRequired,
+ onImportSelectedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContent.defaultProps = {
+ allowSeriesChange: true,
+ showFilterExistingFiles: false,
+ showImportMode: true,
+ importMode: 'move'
+};
+
+export default InteractiveImportModalContent;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
new file mode 100644
index 000000000..3a7b03fb6
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
@@ -0,0 +1,203 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import InteractiveImportModalContent from './InteractiveImportModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('interactiveImport'),
+ (interactiveImport) => {
+ return interactiveImport;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportItems,
+ setInteractiveImportSort,
+ setInteractiveImportMode,
+ clearInteractiveImport,
+ executeCommand
+};
+
+class InteractiveImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ interactiveImportErrorMessage: null,
+ filterExistingFiles: true
+ };
+ }
+
+ componentDidMount() {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ const {
+ filterExistingFiles
+ } = this.state;
+
+ this.props.fetchInteractiveImportItems({
+ downloadId,
+ folder,
+ filterExistingFiles
+ });
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ filterExistingFiles
+ } = this.state;
+
+ if (prevState.filterExistingFiles !== filterExistingFiles) {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ this.props.fetchInteractiveImportItems({
+ downloadId,
+ folder,
+ filterExistingFiles
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearInteractiveImport();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportSort({ sortKey, sortDirection });
+ }
+
+ onFilterExistingFilesChange = (filterExistingFiles) => {
+ this.setState({ filterExistingFiles });
+ }
+
+ onImportModeChange = (importMode) => {
+ this.props.setInteractiveImportMode({ importMode });
+ }
+
+ onImportSelectedPress = (selected, importMode) => {
+ const files = [];
+
+ _.forEach(this.props.items, (item) => {
+ const isSelected = selected.indexOf(item.id) > -1;
+
+ if (isSelected) {
+ const {
+ series,
+ seasonNumber,
+ episodes,
+ quality,
+ language
+ } = item;
+
+ if (!series) {
+ this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' });
+ return false;
+ }
+
+ if (isNaN(seasonNumber)) {
+ this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!episodes || !episodes.length) {
+ this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!quality) {
+ this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!language) {
+ this.setState({ interactiveImportErrorMessage: 'Language must be chosen for each selected file' });
+ return false;
+ }
+
+ files.push({
+ path: item.path,
+ folderName: item.folderName,
+ seriesId: series.id,
+ episodeIds: _.map(episodes, 'id'),
+ quality,
+ language,
+ downloadId: this.props.downloadId
+ });
+ }
+ });
+
+ if (!files.length) {
+ return;
+ }
+
+ this.props.executeCommand({
+ name: commandNames.INTERACTIVE_IMPORT,
+ files,
+ importMode
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ interactiveImportErrorMessage,
+ filterExistingFiles
+ } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+InteractiveImportModalContentConnector.propTypes = {
+ downloadId: PropTypes.string,
+ folder: PropTypes.string,
+ filterExistingFiles: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchInteractiveImportItems: PropTypes.func.isRequired,
+ setInteractiveImportSort: PropTypes.func.isRequired,
+ clearInteractiveImport: PropTypes.func.isRequired,
+ setInteractiveImportMode: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContentConnector.defaultProps = {
+ filterExistingFiles: true
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
new file mode 100644
index 000000000..89f43cebc
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
@@ -0,0 +1,18 @@
+.relativePath {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality,
+.language {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
+
+.label {
+ composes: label from 'Components/Label.css';
+
+ cursor: pointer;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
new file mode 100644
index 000000000..0f84b9a8c
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
@@ -0,0 +1,251 @@
+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 MovieQuality from 'Movie/MovieQuality';
+// import EpisodeLanguage from 'Episode/EpisodeLanguage';
+import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
+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,
+ isSelectQualityModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ const {
+ id,
+ series,
+ quality
+ } = this.props;
+
+ if (
+ series &&
+ quality
+ ) {
+ this.props.onSelectedChange({ id, value: true });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ series,
+ quality,
+ isSelected,
+ onValidRowChange
+ } = this.props;
+
+ if (
+ prevProps.series === series &&
+ prevProps.quality === quality &&
+ prevProps.isSelected === isSelected
+ ) {
+ return;
+ }
+
+ const isValid = !!(
+ series &&
+ 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 });
+ }
+
+ onSelectQualityPress = () => {
+ this.setState({ isSelectQualityModalOpen: true });
+ }
+
+ onSelectSeriesModalClose = (changed) => {
+ this.setState({ isSelectSeriesModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectQualityModalClose = (changed) => {
+ this.setState({ isSelectQualityModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ allowSeriesChange,
+ relativePath,
+ series,
+ quality,
+ size,
+ rejections,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ const {
+ isSelectSeriesModalOpen,
+ isSelectQualityModalOpen
+ } = this.state;
+
+ const seriesTitle = series ? series.title : '';
+
+ const showSeriesPlaceholder = isSelected && !series;
+ const showQualityPlaceholder = isSelected && !quality;
+
+ return (
+
+
+
+
+ {relativePath}
+
+
+
+ {
+ showSeriesPlaceholder ? : seriesTitle
+ }
+
+
+
+ {
+ showQualityPlaceholder &&
+
+ }
+
+ {
+ !showQualityPlaceholder && !!quality &&
+
+ }
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection.reason}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+
+ 1 : false}
+ real={quality ? quality.revision.real > 0 : false}
+ onModalClose={this.onSelectQualityModalClose}
+ />
+
+ );
+ }
+
+}
+
+InteractiveImportRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ allowSeriesChange: PropTypes.bool.isRequired,
+ relativePath: PropTypes.string.isRequired,
+ series: PropTypes.object,
+ seasonNumber: PropTypes.number,
+ episodes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ quality: PropTypes.object,
+ size: PropTypes.number.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onValidRowChange: PropTypes.func.isRequired
+};
+
+InteractiveImportRow.defaultProps = {
+ episodes: []
+};
+
+export default InteractiveImportRow;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css
new file mode 100644
index 000000000..941988144
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css
@@ -0,0 +1,7 @@
+.placeholder {
+ display: inline-block;
+ margin: -8px 0;
+ width: 100%;
+ height: 25px;
+ border: 2px dashed $dangerColor;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js
new file mode 100644
index 000000000..b6744d156
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import styles from './InteractiveImportRowCellPlaceholder.css';
+
+function InteractiveImportRowCellPlaceholder() {
+ return (
+
+ );
+}
+
+export default InteractiveImportRowCellPlaceholder;
diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js
new file mode 100644
index 000000000..0ea6fd9cb
--- /dev/null
+++ b/frontend/src/InteractiveImport/InteractiveImportModal.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector';
+import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector';
+
+class InteractiveImportModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ folder: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isOpen && !this.props.isOpen) {
+ this.setState({ folder: null });
+ }
+ }
+
+ //
+ // Listeners
+
+ onFolderSelect = (folder) => {
+ this.setState({ folder });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ folder,
+ downloadId,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const folderPath = folder || this.state.folder;
+
+ return (
+
+ {
+ folderPath || downloadId ?
+ :
+
+ }
+
+ );
+ }
+}
+
+InteractiveImportModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ folder: PropTypes.string,
+ downloadId: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default InteractiveImportModal;
diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js
new file mode 100644
index 000000000..d3e31d2dd
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectQualityModalContentConnector from './SelectQualityModalContentConnector';
+
+class SelectQualityModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectQualityModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectQualityModal;
diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js
new file mode 100644
index 000000000..642e0433e
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+class SelectQualityModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ qualityId,
+ proper,
+ real
+ } = props;
+
+ this.state = {
+ qualityId,
+ proper,
+ real
+ };
+ }
+
+ //
+ // Listeners
+
+ onQualityChange = ({ value }) => {
+ this.setState({ qualityId: parseInt(value) });
+ }
+
+ onProperChange = ({ value }) => {
+ this.setState({ proper: value });
+ }
+
+ onRealChange = ({ value }) => {
+ this.setState({ real: value });
+ }
+
+ onQualitySelect = () => {
+ this.props.onQualitySelect(this.state);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onModalClose
+ } = this.props;
+
+ const {
+ qualityId,
+ proper,
+ real
+ } = this.state;
+
+ const qualityOptions = items.map(({ id, name }) => {
+ return {
+ key: id,
+ value: name
+ };
+ });
+
+ return (
+
+
+ Manual Import - Select Quality
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load qualities
+ }
+
+ {
+ isPopulated && !error &&
+
+ }
+
+
+
+
+ Cancel
+
+
+
+ Select Quality
+
+
+
+ );
+ }
+}
+
+SelectQualityModalContent.propTypes = {
+ qualityId: PropTypes.number.isRequired,
+ proper: PropTypes.bool.isRequired,
+ real: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onQualitySelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectQualityModalContent;
diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js
new file mode 100644
index 000000000..20a49c768
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js
@@ -0,0 +1,95 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import getQualities from 'Utilities/Quality/getQualities';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import SelectQualityModalContent from './SelectQualityModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = qualityProfiles;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items: getQualities(schema.items)
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ updateInteractiveImportItem
+};
+
+class SelectQualityModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ //
+ // Listeners
+
+ onQualitySelect = ({ qualityId, proper, real }) => {
+ const quality = _.find(this.props.items,
+ (item) => item.id === qualityId);
+
+ const revision = {
+ version: proper ? 2 : 1,
+ real: real ? 1 : 0
+ };
+
+ this.props.updateInteractiveImportItem({
+ id: this.props.id,
+ quality: {
+ quality,
+ revision
+ }
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectQualityModalContentConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModal.js b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js
new file mode 100644
index 000000000..1a1ceffca
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectSeriesModalContentConnector from './SelectSeriesModalContentConnector';
+
+class SelectSeriesModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectSeriesModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectSeriesModal;
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css
new file mode 100644
index 000000000..c22d502f5
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css
@@ -0,0 +1,18 @@
+.modalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.filterInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ flex: 0 0 auto;
+ margin-bottom: 20px;
+}
+
+.scroller {
+ flex: 1 1 auto;
+}
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js
new file mode 100644
index 000000000..6c4c75255
--- /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,
+ onMovieSelect,
+ 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,
+ onMovieSelect: 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..079634183
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import SelectSeriesModalContent from './SelectSeriesModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createAllMoviesSelector(),
+ (items) => {
+ return {
+ items: items.sort((a, b) => {
+ if (a.sortTitle < b.sortTitle) {
+ return -1;
+ }
+
+ if (a.sortTitle > b.sortTitle) {
+ return 1;
+ }
+
+ return 0;
+ })
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateInteractiveImportItem
+};
+
+class SelectSeriesModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onMovieSelect = (seriesId) => {
+ const series = _.find(this.props.items, { id: seriesId });
+
+ this.props.ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ series,
+ seasonNumber: undefined,
+ episodes: []
+ });
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectSeriesModalContentConnector.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.number).isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeriesModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.css b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css
new file mode 100644
index 000000000..f2573d585
--- /dev/null
+++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css
@@ -0,0 +1,4 @@
+.series {
+ padding: 8px;
+ border-bottom: 1px solid $borderColor;
+}
diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.js b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js
new file mode 100644
index 000000000..48ba77094
--- /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.onMovieSelect(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ {this.props.title}
+
+ );
+ }
+}
+
+SelectSeriesRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ onMovieSelect: PropTypes.func.isRequired
+};
+
+export default SelectSeriesRow;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.css b/frontend/src/InteractiveSearch/InteractiveSearch.css
new file mode 100644
index 000000000..5e647332f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearch.css
@@ -0,0 +1,9 @@
+.filterMenuContainer {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 10px;
+}
+
+.filteredMessage {
+ margin-top: 10px;
+}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js
new file mode 100644
index 000000000..453ebd468
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearch.js
@@ -0,0 +1,192 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Icon from 'Components/Icon';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import PageMenuButton from 'Components/Menu/PageMenuButton';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
+import InteractiveSearchRow from './InteractiveSearchRow';
+import styles from './InteractiveSearch.css';
+
+const columns = [
+ {
+ name: 'protocol',
+ label: 'Source',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'age',
+ label: 'Age',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'peers',
+ label: 'Peers',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: '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 InteractiveSearch(props) {
+ const {
+ searchPayload,
+ isFetching,
+ isPopulated,
+ error,
+ totalReleasesCount,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ type,
+ longDateFormat,
+ timeFormat,
+ onSortPress,
+ onFilterSelect,
+ onGrabPress
+ } = props;
+
+ return (
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ Unable to load results for this episode search. Try again later
+
+ }
+
+ {
+ !isFetching && isPopulated && !totalReleasesCount &&
+
+ No results found
+
+ }
+
+ {
+ !!totalReleasesCount && isPopulated && !items.length &&
+
+ All results are hidden by the applied filter
+
+ }
+
+ {
+ isPopulated && !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ totalReleasesCount !== items.length && !!items.length &&
+
+ Some results are hidden by the applied filter
+
+ }
+
+ );
+}
+
+InteractiveSearch.propTypes = {
+ searchPayload: PropTypes.object.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalReleasesCount: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onGrabPress: PropTypes.func.isRequired
+};
+
+export default InteractiveSearch;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js
new file mode 100644
index 000000000..c9f90472b
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as releaseActions from 'Store/Actions/releaseActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import InteractiveSearch from './InteractiveSearch';
+
+function createMapStateToProps(appState, { type }) {
+ return createSelector(
+ (state) => state.releases.items.length,
+ createClientSideCollectionSelector('releases', `releases.${type}`),
+ createUISettingsSelector(),
+ (totalReleasesCount, releases, uiSettings) => {
+ return {
+ totalReleasesCount,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ ...releases
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchReleases(payload) {
+ dispatch(releaseActions.fetchReleases(payload));
+ },
+
+ onSortPress(sortKey, sortDirection) {
+ dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection }));
+ },
+
+ onFilterSelect(selectedFilterKey) {
+ const action = props.type === 'episode' ?
+ releaseActions.setEpisodeReleasesFilter :
+ releaseActions.setSeasonReleasesFilter;
+
+ dispatch(action({ selectedFilterKey }));
+ },
+
+ onGrabPress(payload) {
+ dispatch(releaseActions.grabRelease(payload));
+ }
+ };
+}
+
+class InteractiveSearchConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ searchPayload,
+ isPopulated,
+ dispatchFetchReleases
+ } = this.props;
+
+ // If search results are not yet isPopulated fetch them,
+ // otherwise re-show the existing props.
+
+ if (!isPopulated) {
+ dispatchFetchReleases(searchPayload);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchReleases,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ );
+ }
+}
+
+InteractiveSearchConnector.propTypes = {
+ searchPayload: PropTypes.object.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ dispatchFetchReleases: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js
new file mode 100644
index 000000000..dcbcf340f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setEpisodeReleasesFilter, setSeasonReleasesFilter } from 'Store/Actions/releaseActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.releases.items,
+ (state) => state.releases.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'releases'
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchSetFilter(payload) {
+ const action = props.type === 'episode' ?
+ setEpisodeReleasesFilter:
+ setSeasonReleasesFilter;
+
+ dispatch(action(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal);
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
new file mode 100644
index 000000000..c77b73e7d
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.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/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
new file mode 100644
index 000000000..f303394c7
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
@@ -0,0 +1,253 @@
+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 ConfirmModal from 'Components/Modal/ConfirmModal';
+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 './InteractiveSearchRow.css';
+
+function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
+ if (isGrabbing) {
+ return icons.SPINNER;
+ } else if (isGrabbed) {
+ return icons.DOWNLOADING;
+ } else if (grabError) {
+ return icons.DOWNLOADING;
+ }
+
+ return icons.DOWNLOAD;
+}
+
+function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
+ if (isGrabbing) {
+ return '';
+ } else if (isGrabbed) {
+ return 'Added to downloaded queue';
+ } else if (grabError) {
+ return grabError;
+ }
+
+ return 'Add to downloaded queue';
+}
+
+class InteractiveSearchRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isConfirmGrabModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ const {
+ guid,
+ indexerId,
+ onGrabPress
+ } = this.props;
+
+ onGrabPress({
+ guid,
+ indexerId
+ });
+ }
+
+ onConfirmGrabPress = () => {
+ this.setState({ isConfirmGrabModalOpen: true });
+ }
+
+ onGrabConfirm = () => {
+ this.setState({ isConfirmGrabModalOpen: false });
+
+ const {
+ guid,
+ indexerId,
+ searchPayload,
+ onGrabPress
+ } = this.props;
+
+ onGrabPress({
+ guid,
+ indexerId,
+ ...searchPayload
+ });
+ }
+
+ onGrabCancel = () => {
+ this.setState({ isConfirmGrabModalOpen: false });
+ }
+
+ //
+ // 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}
+ />
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+InteractiveSearchRow.propTypes = {
+ guid: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ age: PropTypes.number.isRequired,
+ ageHours: PropTypes.number.isRequired,
+ ageMinutes: PropTypes.number.isRequired,
+ publishDate: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ infoUrl: PropTypes.string.isRequired,
+ indexerId: PropTypes.number.isRequired,
+ indexer: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ seeders: PropTypes.number,
+ leechers: PropTypes.number,
+ quality: PropTypes.object.isRequired,
+ 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,
+ searchPayload: PropTypes.object.isRequired,
+ onGrabPress: PropTypes.func.isRequired
+};
+
+InteractiveSearchRow.defaultProps = {
+ rejections: [],
+ isGrabbing: false,
+ isGrabbed: false
+};
+
+export default InteractiveSearchRow;
diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/InteractiveSearch/Peers.js
new file mode 100644
index 000000000..66f7cc9f5
--- /dev/null
+++ b/frontend/src/InteractiveSearch/Peers.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function getKind(seeders) {
+ if (seeders > 50) {
+ return kinds.PRIMARY;
+ }
+
+ if (seeders > 10) {
+ return kinds.INFO;
+ }
+
+ if (seeders > 0) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+function getPeersTooltipPart(peers, peersUnit) {
+ if (peers == null) {
+ return `unknown ${peersUnit}s`;
+ }
+
+ if (peers === 1) {
+ return `1 ${peersUnit}`;
+ }
+
+ return `${peers} ${peersUnit}s`;
+}
+
+function Peers(props) {
+ const {
+ seeders,
+ leechers
+ } = props;
+
+ const kind = getKind(seeders);
+
+ return (
+
+ {seeders == null ? '-' : seeders} / {leechers == null ? '-' : leechers}
+
+ );
+}
+
+Peers.propTypes = {
+ seeders: PropTypes.number,
+ leechers: PropTypes.number
+};
+
+export default Peers;
diff --git a/frontend/src/Movie/Delete/DeleteMovieModal.js b/frontend/src/Movie/Delete/DeleteMovieModal.js
new file mode 100644
index 000000000..621b3d8f7
--- /dev/null
+++ b/frontend/src/Movie/Delete/DeleteMovieModal.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 DeleteMovieModalContentConnector from './DeleteMovieModalContentConnector';
+
+function DeleteMovieModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteMovieModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteMovieModal;
diff --git a/frontend/src/Movie/Delete/DeleteMovieModalContent.css b/frontend/src/Movie/Delete/DeleteMovieModalContent.css
new file mode 100644
index 000000000..dbfef0871
--- /dev/null
+++ b/frontend/src/Movie/Delete/DeleteMovieModalContent.css
@@ -0,0 +1,12 @@
+.pathContainer {
+ margin-bottom: 20px;
+}
+
+.pathIcon {
+ margin-right: 8px;
+}
+
+.deleteFilesMessage {
+ margin-top: 20px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Movie/Delete/DeleteMovieModalContent.js b/frontend/src/Movie/Delete/DeleteMovieModalContent.js
new file mode 100644
index 000000000..5cd470f1a
--- /dev/null
+++ b/frontend/src/Movie/Delete/DeleteMovieModalContent.js
@@ -0,0 +1,144 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './DeleteMovieModalContent.css';
+
+class DeleteMovieModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onDeleteMovieConfirmed = () => {
+ const deleteFiles = this.state.deleteFiles;
+
+ this.setState({ deleteFiles: false });
+ this.props.onDeletePress(deleteFiles);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ path,
+ statistics,
+ onModalClose
+ } = this.props;
+
+ const {
+ episodeFileCount,
+ sizeOnDisk
+ } = statistics;
+
+ const deleteFiles = this.state.deleteFiles;
+ let deleteFilesLabel = `Delete ${episodeFileCount} Movie Files`;
+ let deleteFilesHelpText = 'Delete the movie files and movie folder';
+
+ if (episodeFileCount === 0) {
+ deleteFilesLabel = 'Delete Movie Folder';
+ deleteFilesHelpText = 'Delete the movie folder and it\'s contents';
+ }
+
+ return (
+
+
+ Delete - {title}
+
+
+
+
+
+
+ {path}
+
+
+
+ {deleteFilesLabel}
+
+
+
+
+ {
+ deleteFiles &&
+
+
The movie folder {path} and all it's content will be deleted.
+
+ {
+ !!episodeFileCount &&
+
{episodeFileCount} movie files totaling {formatBytes(sizeOnDisk)}
+ }
+
+ }
+
+
+
+
+
+ Close
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteMovieModalContent.propTypes = {
+ title: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+DeleteMovieModalContent.defaultProps = {
+ statistics: {
+ episodeFileCount: 0
+ }
+};
+
+export default DeleteMovieModalContent;
diff --git a/frontend/src/Movie/Delete/DeleteMovieModalContentConnector.js b/frontend/src/Movie/Delete/DeleteMovieModalContentConnector.js
new file mode 100644
index 000000000..d02c30294
--- /dev/null
+++ b/frontend/src/Movie/Delete/DeleteMovieModalContentConnector.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 createMovieSelector from 'Store/Selectors/createMovieSelector';
+import { deleteMovie } from 'Store/Actions/movieActions';
+import DeleteMovieModalContent from './DeleteMovieModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieSelector(),
+ (movie) => {
+ return movie;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteMovie
+};
+
+class DeleteMovieModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = (deleteFiles) => {
+ this.props.deleteMovie({
+ id: this.props.movieId,
+ deleteFiles
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DeleteMovieModalContentConnector.propTypes = {
+ movieId: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ deleteMovie: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DeleteMovieModalContentConnector);
diff --git a/frontend/src/Movie/Details/MovieAlternateTitles.css b/frontend/src/Movie/Details/MovieAlternateTitles.css
new file mode 100644
index 000000000..1af1ae68b
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieAlternateTitles.css
@@ -0,0 +1,3 @@
+.alternateTitle {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Movie/Details/MovieAlternateTitles.js b/frontend/src/Movie/Details/MovieAlternateTitles.js
new file mode 100644
index 000000000..39cd0f93e
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieAlternateTitles.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './MovieAlternateTitles.css';
+
+function MovieAlternateTitles({ alternateTitles }) {
+ return (
+
+ {
+ alternateTitles.map((alternateTitle) => {
+ return (
+
+ {alternateTitle}
+
+ );
+ })
+ }
+
+ );
+}
+
+MovieAlternateTitles.propTypes = {
+ alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default MovieAlternateTitles;
diff --git a/frontend/src/Movie/Details/MovieDetails.css b/frontend/src/Movie/Details/MovieDetails.css
new file mode 100644
index 000000000..f161e524a
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieDetails.css
@@ -0,0 +1,155 @@
+.innerContentBody {
+ padding: 0;
+}
+
+.header {
+ position: relative;
+ width: 100%;
+ height: 425px;
+}
+
+.backdrop {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+}
+
+.backdropOverlay {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background: $black;
+ opacity: 0.7;
+}
+
+.headerContent {
+ display: flex;
+ padding: 30px;
+ width: 100%;
+ height: 100%;
+ color: $white;
+}
+
+.poster {
+ flex-shrink: 0;
+ margin-right: 35px;
+ width: 250px;
+ height: 368px;
+}
+
+.info {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.titleRow {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 auto;
+}
+
+.titleContainer {
+ display: flex;
+ margin-bottom: 5px;
+}
+
+.title {
+ font-weight: 300;
+ font-size: 50px;
+ line-height: 50px;
+}
+
+.toggleMonitoredContainer {
+ align-self: center;
+ margin-right: 10px;
+}
+
+.monitorToggleButton {
+ composes: toggleButton from 'Components/MonitorToggleButton.css';
+
+ width: 40px;
+
+ &:hover {
+ color: $iconButtonHoverLightColor;
+ }
+}
+
+.alternateTitlesIconContainer {
+ align-self: flex-end;
+ margin-left: 20px;
+}
+
+.seriesNavigationButtons {
+ white-space: nowrap;
+}
+
+.seriesNavigationButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ margin-left: 5px;
+ width: 30px;
+ color: #e1e2e3;
+ white-space: nowrap;
+
+ &:hover {
+ color: $iconButtonHoverLightColor;
+ }
+}
+
+.details {
+ margin-bottom: 8px;
+ font-weight: 300;
+ font-size: 20px;
+}
+
+.runtime {
+ margin-right: 15px;
+}
+
+.detailsLabel {
+ composes: label from 'Components/Label.css';
+
+ margin: 5px 10px 5px 0;
+}
+
+.path,
+.sizeOnDisk,
+.qualityProfileName,
+.network,
+.links,
+.tags {
+ margin-left: 8px;
+ font-weight: 300;
+ font-size: 17px;
+}
+
+.overview {
+ flex: 1 0 auto;
+ margin-top: 8px;
+ min-height: 0;
+ font-size: $intermediateFontSize;
+}
+
+.contentContainer {
+ padding: 20px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentContainer {
+ padding: 20px 0;
+ }
+
+ .headerContent {
+ padding: 15px;
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .poster {
+ display: none;
+ }
+}
diff --git a/frontend/src/Movie/Details/MovieDetails.js b/frontend/src/Movie/Details/MovieDetails.js
new file mode 100644
index 000000000..6970b8b48
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieDetails.js
@@ -0,0 +1,593 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
+import fonts from 'Styles/Variables/fonts';
+import HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+import Measure from 'Components/Measure';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import Popover from 'Components/Tooltip/Popover';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import MovieFileEditorModal from 'MovieFile/Editor/MovieFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import MoviePoster from 'Movie/MoviePoster';
+import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
+import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
+import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
+import MovieAlternateTitles from './MovieAlternateTitles';
+import MovieTagsConnector from './MovieTagsConnector';
+import MovieDetailsLinks from './MovieDetailsLinks';
+import styles from './MovieDetails.css';
+import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
+
+const defaultFontSize = parseInt(fonts.defaultFontSize);
+const lineHeight = parseFloat(fonts.lineHeight);
+
+function getFanartUrl(images) {
+ const fanartImage = _.find(images, { coverType: 'fanart' });
+ if (fanartImage) {
+ // Remove protocol
+ return fanartImage.url.replace(/^https?:/, '');
+ }
+}
+
+function getExpandedState(newState) {
+ return {
+ allExpanded: newState.allSelected,
+ allCollapsed: newState.allUnselected,
+ expandedState: newState.selectedState
+ };
+}
+
+class MovieDetails extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isManageEpisodesOpen: false,
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: false,
+ isMovieHistoryModalOpen: false,
+ isInteractiveImportModalOpen: false,
+ allExpanded: false,
+ allCollapsed: false,
+ expandedState: {},
+ overviewHeight: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onManageEpisodesPress = () => {
+ this.setState({ isManageEpisodesOpen: true });
+ }
+
+ onManageEpisodesModalClose = () => {
+ this.setState({ isManageEpisodesOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ onEditMoviePress = () => {
+ this.setState({ isEditMovieModalOpen: true });
+ }
+
+ onEditMovieModalClose = () => {
+ this.setState({ isEditMovieModalOpen: false });
+ }
+
+ onDeleteMoviePress = () => {
+ this.setState({
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: true
+ });
+ }
+
+ onDeleteMovieModalClose = () => {
+ this.setState({ isDeleteMovieModalOpen: false });
+ }
+
+ onMovieHistoryPress = () => {
+ this.setState({ isMovieHistoryModalOpen: true });
+ }
+
+ onMovieHistoryModalClose = () => {
+ this.setState({ isMovieHistoryModalOpen: false });
+ }
+
+ onExpandAllPress = () => {
+ const {
+ allExpanded,
+ expandedState
+ } = this.state;
+
+ this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
+ }
+
+ onExpandPress = (seasonNumber, isExpanded) => {
+ this.setState((state) => {
+ const convertedState = {
+ allSelected: state.allExpanded,
+ allUnselected: state.allCollapsed,
+ selectedState: state.expandedState
+ };
+
+ const newState = toggleSelected(convertedState, [], seasonNumber, isExpanded, false);
+
+ return getExpandedState(newState);
+ });
+ }
+
+ onMeasure = ({ height }) => {
+ this.setState({ overviewHeight: height });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ tmdbId,
+ imdbId,
+ title,
+ runtime,
+ ratings,
+ path,
+ sizeOnDisk,
+ qualityProfileId,
+ monitored,
+ studio,
+ overview,
+ images,
+ alternateTitles,
+ tags,
+ isSaving,
+ isRefreshing,
+ isSearching,
+ isFetching,
+ isPopulated,
+ movieFilesError,
+ hasMovieFiles,
+ previousMovie,
+ nextMovie,
+ onMonitorTogglePress,
+ onRefreshPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ isOrganizeModalOpen,
+ isManageEpisodesOpen,
+ isEditMovieModalOpen,
+ isDeleteMovieModalOpen,
+ isMovieHistoryModalOpen,
+ isInteractiveImportModalOpen,
+ overviewHeight
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+ {
+ !!alternateTitles.length &&
+
+
+ }
+ title="Alternate Titles"
+ body={
}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !!runtime &&
+
+ {runtime} Minutes
+
+ }
+
+
+
+
+
+
+
+
+
+
+ {path}
+
+
+
+
+
+
+
+ {
+ formatBytes(sizeOnDisk)
+ }
+
+
+
+
+
+
+
+ {
+
+ }
+
+
+
+
+
+
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+
+
+ {
+ !!studio &&
+
+
+
+
+ {studio}
+
+
+ }
+
+
+
+
+
+ Links
+
+
+ }
+ tooltip={
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ {
+ !!tags.length &&
+
+
+
+
+ Tags
+
+
+ }
+ tooltip={ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !isPopulated && !movieFilesError &&
+
+ }
+
+ {
+ !isFetching && movieFilesError &&
+
Loading movie files failed
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MovieDetails.propTypes = {
+ id: PropTypes.number.isRequired,
+ tmdbId: PropTypes.number.isRequired,
+ imdbId: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ runtime: PropTypes.number.isRequired,
+ ratings: PropTypes.object.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ studio: PropTypes.string,
+ overview: PropTypes.string.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ isRefreshing: PropTypes.bool.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ movieFilesError: PropTypes.object,
+ hasMovieFiles: PropTypes.bool.isRequired,
+ previousMovie: PropTypes.object.isRequired,
+ nextMovie: PropTypes.object.isRequired,
+ onMonitorTogglePress: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+MovieDetails.defaultProps = {
+ tag: [],
+ isSaving: false
+};
+
+export default MovieDetails;
diff --git a/frontend/src/Movie/Details/MovieDetailsConnector.js b/frontend/src/Movie/Details/MovieDetailsConnector.js
new file mode 100644
index 000000000..9c44b6083
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieDetailsConnector.js
@@ -0,0 +1,229 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand, isCommandExecuting } from 'Utilities/Command';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { fetchMovieFiles, clearMovieFiles } from 'Store/Actions/movieFileActions';
+import { toggleMovieMonitored } from 'Store/Actions/movieActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import MovieDetails from './MovieDetails';
+
+const selectMovieFiles = createSelector(
+ (state) => state.movieFiles,
+ (movieFiles) => {
+ const {
+ items,
+ isFetching,
+ isPopulated,
+ error
+ } = movieFiles;
+
+ const hasMovieFiles = !!items.length;
+
+ return {
+ isMovieFilesFetching: isFetching,
+ isMovieFilesPopulated: isPopulated,
+ movieFilesError: error,
+ hasMovieFiles
+ };
+ }
+);
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { titleSlug }) => titleSlug,
+ selectMovieFiles,
+ createAllMoviesSelector(),
+ createCommandsSelector(),
+ (titleSlug, movieFiles, allMovies, commands) => {
+ const sortedMovies = _.orderBy(allMovies, 'sortTitle');
+ const movieIndex = _.findIndex(sortedMovies, { titleSlug });
+ const movie = sortedMovies[movieIndex];
+
+ if (!movie) {
+ return {};
+ }
+
+ const {
+ isMovieFilesFetching,
+ isMovieFilesPopulated,
+ episodeFilesError,
+ hasMovieFiles
+ } = movieFiles;
+
+ const previousMovie = sortedMovies[movieIndex - 1] || _.last(sortedMovies);
+ const nextMovie = sortedMovies[movieIndex + 1] || _.first(sortedMovies);
+ const isMovieRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_MOVIE, movieId: movie.id }));
+ const movieRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_MOVIE });
+ const allMoviesRefreshing = (
+ isCommandExecuting(movieRefreshingCommand) &&
+ !movieRefreshingCommand.body.movieId
+ );
+ const isRefreshing = isMovieRefreshing || allMoviesRefreshing;
+ const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.MOVIE_SEARCH, movieIds: [movie.id] }));
+ const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, movieId: movie.id }));
+ const isRenamingMovieCommand = findCommand(commands, { name: commandNames.RENAME_SERIES });
+ const isRenamingMovie = (
+ isCommandExecuting(isRenamingMovieCommand) &&
+ isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1
+ );
+
+ const isFetching = isMovieFilesFetching;
+ const isPopulated = isMovieFilesPopulated;
+ const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => {
+ if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
+ (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
+ acc.push(alternateTitle.title);
+ }
+
+ return acc;
+ }, []);
+
+ return {
+ ...movie,
+ alternateTitles,
+ isMovieRefreshing,
+ allMoviesRefreshing,
+ isRefreshing,
+ isSearching,
+ isRenamingFiles,
+ isRenamingMovie,
+ isFetching,
+ isPopulated,
+ episodeFilesError,
+ hasMovieFiles,
+ previousMovie,
+ nextMovie
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMovieFiles,
+ clearMovieFiles,
+ toggleMovieMonitored,
+ fetchQueueDetails,
+ clearQueueDetails,
+ executeCommand
+};
+
+class MovieDetailsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.populate);
+ this.populate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ isMovieRefreshing,
+ allMoviesRefreshing,
+ isRenamingFiles,
+ isRenamingMovie
+ } = this.props;
+
+ if (
+ (prevProps.isMovieRefreshing && !isMovieRefreshing) ||
+ (prevProps.allMoviesRefreshing && !allMoviesRefreshing) ||
+ (prevProps.isRenamingFiles && !isRenamingFiles) ||
+ (prevProps.isRenamingMovie && !isRenamingMovie)
+ ) {
+ this.populate();
+ }
+
+ // If the id has changed we need to clear the episodes/episode
+ // files and fetch from the server.
+
+ if (prevProps.id !== id) {
+ this.unpopulate();
+ this.populate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.populate);
+ this.unpopulate();
+ }
+
+ //
+ // Control
+
+ populate = () => {
+ const movieId = this.props.id;
+
+ this.props.fetchMovieFiles({ movieId });
+ this.props.fetchQueueDetails({ movieId });
+ }
+
+ unpopulate = () => {
+ this.props.clearMovieFiles();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Listeners
+
+ onMonitorTogglePress = (monitored) => {
+ this.props.toggleMovieMonitored({
+ movieId: this.props.id,
+ monitored
+ });
+ }
+
+ onRefreshPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_MOVIE,
+ movieId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.MOVIE_SEARCH,
+ movieIds: [this.props.id]
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MovieDetailsConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ isMovieRefreshing: PropTypes.bool.isRequired,
+ allMoviesRefreshing: PropTypes.bool.isRequired,
+ isRefreshing: PropTypes.bool.isRequired,
+ isRenamingFiles: PropTypes.bool.isRequired,
+ isRenamingMovie: PropTypes.bool.isRequired,
+ fetchMovieFiles: PropTypes.func.isRequired,
+ clearMovieFiles: PropTypes.func.isRequired,
+ toggleMovieMonitored: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MovieDetailsConnector);
diff --git a/frontend/src/Movie/Details/MovieDetailsLinks.css b/frontend/src/Movie/Details/MovieDetailsLinks.css
new file mode 100644
index 000000000..0f65b9154
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieDetailsLinks.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/Movie/Details/MovieDetailsLinks.js b/frontend/src/Movie/Details/MovieDetailsLinks.js
new file mode 100644
index 000000000..ce51fe900
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieDetailsLinks.js
@@ -0,0 +1,66 @@
+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 './MovieDetailsLinks.css';
+
+function MovieDetailsLinks(props) {
+ const {
+ tmdbId,
+ imdbId
+ } = props;
+
+ return (
+
+
+
+ The Movie DB
+
+
+
+
+
+ Trakt
+
+
+
+ {
+ !!imdbId &&
+
+
+ IMDB
+
+
+ }
+
+ );
+}
+
+MovieDetailsLinks.propTypes = {
+ tmdbId: PropTypes.number.isRequired,
+ imdbId: PropTypes.string
+};
+
+export default MovieDetailsLinks;
diff --git a/frontend/src/Movie/Details/MovieDetailsPageConnector.js b/frontend/src/Movie/Details/MovieDetailsPageConnector.js
new file mode 100644
index 000000000..a9c4d6526
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieDetailsPageConnector.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 createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import NotFound from 'Components/NotFound';
+import MovieDetailsConnector from './MovieDetailsConnector';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ createAllMoviesSelector(),
+ (match, allMovies) => {
+ const titleSlug = match.params.titleSlug;
+ const movieIndex = _.findIndex(allMovies, { titleSlug });
+
+ if (movieIndex > -1) {
+ return {
+ titleSlug
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ push
+};
+
+class MovieDetailsPageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.titleSlug) {
+ this.props.push(`${window.Radarr.urlBase}/`);
+ return;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ titleSlug
+ } = this.props;
+
+ if (!titleSlug) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+MovieDetailsPageConnector.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)(MovieDetailsPageConnector);
diff --git a/frontend/src/Movie/Details/MovieTags.js b/frontend/src/Movie/Details/MovieTags.js
new file mode 100644
index 000000000..93bb00ae0
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieTags.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function MovieTags({ tags }) {
+ return (
+
+ {
+ tags.map((tag) => {
+ return (
+
+ {tag}
+
+ );
+ })
+ }
+
+ );
+}
+
+MovieTags.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default MovieTags;
diff --git a/frontend/src/Movie/Details/MovieTagsConnector.js b/frontend/src/Movie/Details/MovieTagsConnector.js
new file mode 100644
index 000000000..a0f8c6a63
--- /dev/null
+++ b/frontend/src/Movie/Details/MovieTagsConnector.js
@@ -0,0 +1,30 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import MovieTags from './MovieTags';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieSelector(),
+ 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)(MovieTags);
diff --git a/frontend/src/Movie/Edit/EditMovieModal.js b/frontend/src/Movie/Edit/EditMovieModal.js
new file mode 100644
index 000000000..24d9e432a
--- /dev/null
+++ b/frontend/src/Movie/Edit/EditMovieModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditMovieModalContentConnector from './EditMovieModalContentConnector';
+
+function EditMovieModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditMovieModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditMovieModal;
diff --git a/frontend/src/Movie/Edit/EditMovieModalConnector.js b/frontend/src/Movie/Edit/EditMovieModalConnector.js
new file mode 100644
index 000000000..affea9d0c
--- /dev/null
+++ b/frontend/src/Movie/Edit/EditMovieModalConnector.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 EditMovieModal from './EditMovieModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditMovieModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'movies' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMovieModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(EditMovieModalConnector);
diff --git a/frontend/src/Movie/Edit/EditMovieModalContent.css b/frontend/src/Movie/Edit/EditMovieModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Movie/Edit/EditMovieModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Movie/Edit/EditMovieModalContent.js b/frontend/src/Movie/Edit/EditMovieModalContent.js
new file mode 100644
index 000000000..8fb17a9c6
--- /dev/null
+++ b/frontend/src/Movie/Edit/EditMovieModalContent.js
@@ -0,0 +1,182 @@
+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 MoveMovieModal from 'Movie/MoveSeries/MoveSeriesModal';
+import styles from './EditMovieModalContent.css';
+
+class EditMovieModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isConfirmMoveModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onSavePress = () => {
+ const {
+ isPathChanging,
+ onSavePress
+ } = this.props;
+
+ if (isPathChanging && !this.state.isConfirmMoveModalOpen) {
+ this.setState({ isConfirmMoveModalOpen: true });
+ } else {
+ this.setState({ isConfirmMoveModalOpen: false });
+
+ onSavePress(false);
+ }
+ }
+
+ onMoveSeriesPress = () => {
+ this.setState({ isConfirmMoveModalOpen: false });
+
+ this.props.onSavePress(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ item,
+ isSaving,
+ originalPath,
+ onInputChange,
+ onModalClose,
+ onDeleteMoviePress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ monitored,
+ qualityProfileId,
+ // Id,
+ path,
+ tags
+ } = item;
+
+ return (
+
+
+ Edit - {title}
+
+
+
+
+
+
+
+
+ Delete
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+
+ );
+ }
+}
+
+EditMovieModalContent.propTypes = {
+ movieId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ item: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ isPathChanging: PropTypes.bool.isRequired,
+ originalPath: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteMoviePress: PropTypes.func.isRequired
+};
+
+export default EditMovieModalContent;
diff --git a/frontend/src/Movie/Edit/EditMovieModalContentConnector.js b/frontend/src/Movie/Edit/EditMovieModalContentConnector.js
new file mode 100644
index 000000000..7f6b11860
--- /dev/null
+++ b/frontend/src/Movie/Edit/EditMovieModalContentConnector.js
@@ -0,0 +1,115 @@
+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 createMovieSelector from 'Store/Selectors/createMovieSelector';
+import { setMovieValue, saveMovie } from 'Store/Actions/movieActions';
+import EditMovieModalContent from './EditMovieModalContent';
+
+function createIsPathChangingSelector() {
+ return createSelector(
+ (state) => state.movies.pendingChanges,
+ createMovieSelector(),
+ (pendingChanges, movie) => {
+ const path = pendingChanges.path;
+
+ if (path == null) {
+ return false;
+ }
+
+ return movie.path !== path;
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movies,
+ createMovieSelector(),
+ createIsPathChangingSelector(),
+ (seriesState, movie, isPathChanging) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges
+ } = seriesState;
+
+ const seriesSettings = _.pick(movie, [
+ 'monitored',
+ 'qualityProfileId',
+ 'path',
+ 'tags'
+ ]);
+
+ const settings = selectSettings(seriesSettings, pendingChanges, saveError);
+
+ return {
+ title: movie.title,
+ isSaving,
+ saveError,
+ isPathChanging,
+ originalPath: movie.path,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetMovieValue: setMovieValue,
+ dispatchSaveMovie: saveMovie
+};
+
+class EditMovieModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetMovieValue({ name, value });
+ }
+
+ onSavePress = (moveFiles) => {
+ this.props.dispatchSaveMovie({
+ id: this.props.movieId,
+ moveFiles
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMovieModalContentConnector.propTypes = {
+ movieId: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ dispatchSetMovieValue: PropTypes.func.isRequired,
+ dispatchSaveMovie: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditMovieModalContentConnector);
diff --git a/frontend/src/Movie/Editor/Delete/DeleteMovieModal.js b/frontend/src/Movie/Editor/Delete/DeleteMovieModal.js
new file mode 100644
index 000000000..8a9a80b16
--- /dev/null
+++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DeleteMovieModalContentConnector from './DeleteMovieModalContentConnector';
+
+function DeleteMovieModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteMovieModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteMovieModal;
diff --git a/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.css b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.css
new file mode 100644
index 000000000..950fdc27d
--- /dev/null
+++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.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/Movie/Editor/Delete/DeleteMovieModalContent.js b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.js
new file mode 100644
index 000000000..94ad87a34
--- /dev/null
+++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.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 './DeleteMovieModalContent.css';
+
+class DeleteMovieModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onDeleteMovieConfirmed = () => {
+ 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
+
+
+
+ );
+ }
+}
+
+DeleteMovieModalContent.propTypes = {
+ series: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteSelectedPress: PropTypes.func.isRequired
+};
+
+export default DeleteMovieModalContent;
diff --git a/frontend/src/Movie/Editor/Delete/DeleteMovieModalContentConnector.js b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContentConnector.js
new file mode 100644
index 000000000..2ff44ea29
--- /dev/null
+++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContentConnector.js
@@ -0,0 +1,45 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import { bulkDeleteMovie } from 'Store/Actions/movieEditorActions';
+import DeleteMovieModalContent from './DeleteMovieModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seriesIds }) => seriesIds,
+ createAllMoviesSelector(),
+ (seriesIds, allMovies) => {
+ const selectedMovie = _.intersectionWith(allMovies, seriesIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedSeries = _.orderBy(selectedMovie, 'sortTitle');
+ const series = _.map(sortedSeries, (s) => {
+ return {
+ title: s.title,
+ path: s.path
+ };
+ });
+
+ return {
+ series
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDeleteSelectedPress(deleteFiles) {
+ dispatch(bulkDeleteMovie({
+ seriesIds: props.seriesIds,
+ deleteFiles
+ }));
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteMovieModalContent);
diff --git a/frontend/src/Movie/Editor/Organize/OrganizeSeriesModal.js b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModal.js
new file mode 100644
index 000000000..c970392ec
--- /dev/null
+++ b/frontend/src/Movie/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/Movie/Editor/Organize/OrganizeSeriesModalContent.css b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.css
new file mode 100644
index 000000000..0b896f4ef
--- /dev/null
+++ b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.css
@@ -0,0 +1,8 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.js b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.js
new file mode 100644
index 000000000..10a459d52
--- /dev/null
+++ b/frontend/src/Movie/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/Movie/Editor/Organize/OrganizeSeriesModalContentConnector.js b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContentConnector.js
new file mode 100644
index 000000000..95fa7ebd6
--- /dev/null
+++ b/frontend/src/Movie/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 createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import OrganizeSeriesModalContent from './OrganizeSeriesModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seriesIds }) => seriesIds,
+ createAllMoviesSelector(),
+ (seriesIds, allMovies) => {
+ const series = _.intersectionWith(allMovies, seriesIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedSeries = _.orderBy(series, 'sortTitle');
+ const seriesTitles = _.map(sortedSeries, 'title');
+
+ return {
+ seriesTitles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class OrganizeSeriesModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onOrganizeSeriesPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RENAME_SERIES,
+ seriesIds: this.props.seriesIds
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render(props) {
+ return (
+
+ );
+ }
+}
+
+OrganizeSeriesModalContentConnector.propTypes = {
+ seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeSeriesModalContentConnector);
diff --git a/frontend/src/Movie/Editor/SeriesEditor.js b/frontend/src/Movie/Editor/SeriesEditor.js
new file mode 100644
index 000000000..401cb73ab
--- /dev/null
+++ b/frontend/src/Movie/Editor/SeriesEditor.js
@@ -0,0 +1,268 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import NoMovie from 'Movie/NoMovie';
+import OrganizeSeriesModal from './Organize/OrganizeSeriesModal';
+import SeriesEditorRowConnector from './SeriesEditorRowConnector';
+import SeriesEditorFooter from './SeriesEditorFooter';
+import SeriesEditorFilterModalConnector from './SeriesEditorFilterModalConnector';
+
+function getColumns() {
+ return [
+ {
+ name: 'status',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'sortTitle',
+ label: 'Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: '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()
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDeleting,
+ deleteError
+ } = this.props;
+
+ const hasFinishedDeleting = prevProps.isDeleting &&
+ !isDeleting &&
+ !deleteError;
+
+ if (hasFinishedDeleting) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSaveSelected = (changes) => {
+ this.props.onSaveSelected({
+ seriesIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ onOrganizeSeriesPress = () => {
+ this.setState({ isOrganizingSeriesModalOpen: true });
+ }
+
+ onOrganizeSeriesModalClose = (organized) => {
+ this.setState({ isOrganizingSeriesModalOpen: false });
+
+ if (organized === true) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ totalItems,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ isDeleting,
+ deleteError,
+ isOrganizingSeries,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ columns
+ } = this.state;
+
+ const selectedMovieIds = this.getSelectedIds();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesEditor.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalItems: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ isOrganizingSeries: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSaveSelected: PropTypes.func.isRequired
+};
+
+export default SeriesEditor;
diff --git a/frontend/src/Movie/Editor/SeriesEditorConnector.js b/frontend/src/Movie/Editor/SeriesEditorConnector.js
new file mode 100644
index 000000000..fc1901ac9
--- /dev/null
+++ b/frontend/src/Movie/Editor/SeriesEditorConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { setSeriesEditorSort, setSeriesEditorFilter, saveSeriesEditor } from 'Store/Actions/movieEditorActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import SeriesEditor from './SeriesEditor';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('series', 'movieEditor'),
+ createCommandExecutingSelector(commandNames.RENAME_SERIES),
+ (series, isOrganizingSeries) => {
+ return {
+ isOrganizingSeries,
+ ...series
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetSeriesEditorSort: setSeriesEditorSort,
+ dispatchSetSeriesEditorFilter: setSeriesEditorFilter,
+ dispatchSaveMovieEditor: saveSeriesEditor,
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchExecuteCommand: executeCommand
+};
+
+class SeriesEditorConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.dispatchSetSeriesEditorSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.dispatchSetSeriesEditorFilter({ selectedFilterKey });
+ }
+
+ onSaveSelected = (payload) => {
+ this.props.dispatchSaveMovieEditor(payload);
+ }
+
+ onMoveSelected = (payload) => {
+ this.props.dispatchExecuteCommand({
+ name: commandNames.MOVE_SERIES,
+ ...payload
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SeriesEditorConnector.propTypes = {
+ dispatchSetSeriesEditorSort: PropTypes.func.isRequired,
+ dispatchSetSeriesEditorFilter: PropTypes.func.isRequired,
+ dispatchSaveMovieEditor: PropTypes.func.isRequired,
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchExecuteCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SeriesEditorConnector);
diff --git a/frontend/src/Movie/Editor/SeriesEditorFilterModalConnector.js b/frontend/src/Movie/Editor/SeriesEditorFilterModalConnector.js
new file mode 100644
index 000000000..ff9ab119e
--- /dev/null
+++ b/frontend/src/Movie/Editor/SeriesEditorFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setSeriesEditorFilter } from 'Store/Actions/movieEditorActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movies.items,
+ (state) => state.moviesEditor.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'movieEditor'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setSeriesEditorFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/Movie/Editor/SeriesEditorFooter.css b/frontend/src/Movie/Editor/SeriesEditorFooter.css
new file mode 100644
index 000000000..815c700d3
--- /dev/null
+++ b/frontend/src/Movie/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;
+ }
+
+ .selectedMovieLabel {
+ text-align: left;
+ }
+}
diff --git a/frontend/src/Movie/Editor/SeriesEditorFooter.js b/frontend/src/Movie/Editor/SeriesEditorFooter.js
new file mode 100644
index 000000000..d49940213
--- /dev/null
+++ b/frontend/src/Movie/Editor/SeriesEditorFooter.js
@@ -0,0 +1,283 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import SelectInput from 'Components/Form/SelectInput';
+import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector';
+import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import MoveSeriesModal from 'Movie/MoveSeries/MoveSeriesModal';
+import TagsModal from './Tags/TagsModal';
+import DeleteMovieModal from './Delete/DeleteMovieModal';
+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,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false,
+ isDeleteMovieModalOpen: false,
+ isTagsModalOpen: false,
+ isConfirmMoveModalOpen: false,
+ destinationRootFolder: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.setState({
+ monitored: NO_CHANGE,
+ qualityProfileId: NO_CHANGE,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+
+ if (value === NO_CHANGE) {
+ return;
+ }
+
+ switch (name) {
+ case 'rootFolderPath':
+ this.setState({
+ isConfirmMoveModalOpen: true,
+ destinationRootFolder: value
+ });
+ break;
+ case 'monitored':
+ this.props.onSaveSelected({ [name]: value === 'monitored' });
+ break;
+ default:
+ this.props.onSaveSelected({ [name]: value });
+ }
+ }
+
+ onApplyTagsPress = (tags, applyTags) => {
+ this.setState({
+ savingTags: true,
+ isTagsModalOpen: false
+ });
+
+ this.props.onSaveSelected({
+ tags,
+ applyTags
+ });
+ }
+
+ onDeleteSelectedPress = () => {
+ this.setState({ isDeleteMovieModalOpen: true });
+ }
+
+ onDeleteMovieModalClose = () => {
+ this.setState({ isDeleteMovieModalOpen: false });
+ }
+
+ onTagsPress = () => {
+ this.setState({ isTagsModalOpen: true });
+ }
+
+ onTagsModalClose = () => {
+ this.setState({ isTagsModalOpen: false });
+ }
+
+ onSaveRootFolderPress = () => {
+ this.setState({
+ isConfirmMoveModalOpen: false,
+ destinationRootFolder: null
+ });
+
+ this.props.onSaveSelected({ rootFolderPath: this.state.destinationRootFolder });
+ }
+
+ onMoveSeriesPress = () => {
+ this.setState({
+ isConfirmMoveModalOpen: false,
+ destinationRootFolder: null
+ });
+
+ this.props.onSaveSelected({
+ rootFolderPath: this.state.destinationRootFolder,
+ moveFiles: true
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ seriesIds,
+ selectedCount,
+ isSaving,
+ isDeleting,
+ isOrganizingSeries,
+ onOrganizeSeriesPress
+ } = this.props;
+
+ const {
+ monitored,
+ qualityProfileId,
+ rootFolderPath,
+ savingTags,
+ isTagsModalOpen,
+ isDeleteMovieModalOpen,
+ isConfirmMoveModalOpen,
+ destinationRootFolder
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored' },
+ { key: 'unmonitored', value: 'Unmonitored' }
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rename Files
+
+
+
+ Set Tags
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesEditorFooter.propTypes = {
+ seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ selectedCount: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ isOrganizingSeries: PropTypes.bool.isRequired,
+ onSaveSelected: PropTypes.func.isRequired,
+ onOrganizeSeriesPress: PropTypes.func.isRequired
+};
+
+export default SeriesEditorFooter;
diff --git a/frontend/src/Movie/Editor/SeriesEditorFooterLabel.css b/frontend/src/Movie/Editor/SeriesEditorFooterLabel.css
new file mode 100644
index 000000000..9b4b40be6
--- /dev/null
+++ b/frontend/src/Movie/Editor/SeriesEditorFooterLabel.css
@@ -0,0 +1,8 @@
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.savingIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Movie/Editor/SeriesEditorFooterLabel.js b/frontend/src/Movie/Editor/SeriesEditorFooterLabel.js
new file mode 100644
index 000000000..fc77ece44
--- /dev/null
+++ b/frontend/src/Movie/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/Movie/Editor/SeriesEditorRow.js b/frontend/src/Movie/Editor/SeriesEditorRow.js
new file mode 100644
index 000000000..742e34925
--- /dev/null
+++ b/frontend/src/Movie/Editor/SeriesEditorRow.js
@@ -0,0 +1,97 @@
+// 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 MovieTitleLink from 'Movie/MovieTitleLink';
+import MovieStatusCell from 'Movie/Index/Table/MovieStatusCell';
+
+class SeriesEditorRow extends Component {
+
+ //
+ // Listeners
+
+ onSeasonFolderChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ status,
+ titleSlug,
+ title,
+ monitored,
+ qualityProfile,
+ path,
+ tags,
+ // columns,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {qualityProfile.name}
+
+
+
+ {path}
+
+
+
+
+
+
+ );
+ }
+}
+
+SeriesEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ status: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ path: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+SeriesEditorRow.defaultProps = {
+ tags: []
+};
+
+export default SeriesEditorRow;
diff --git a/frontend/src/Movie/Editor/SeriesEditorRowConnector.js b/frontend/src/Movie/Editor/SeriesEditorRowConnector.js
new file mode 100644
index 000000000..1b70e92fe
--- /dev/null
+++ b/frontend/src/Movie/Editor/SeriesEditorRowConnector.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';
+import SeriesEditorRow from './SeriesEditorRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createQualityProfileSelector(),
+ (qualityProfile) => {
+ return {
+ qualityProfile
+ };
+ }
+ );
+}
+
+function SeriesEditorRowConnector(props) {
+ return (
+
+ );
+}
+
+SeriesEditorRowConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps)(SeriesEditorRowConnector);
diff --git a/frontend/src/Movie/Editor/Tags/TagsModal.js b/frontend/src/Movie/Editor/Tags/TagsModal.js
new file mode 100644
index 000000000..0f6c2d7ec
--- /dev/null
+++ b/frontend/src/Movie/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/Movie/Editor/Tags/TagsModalContent.css b/frontend/src/Movie/Editor/Tags/TagsModalContent.css
new file mode 100644
index 000000000..63be9aadd
--- /dev/null
+++ b/frontend/src/Movie/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/Movie/Editor/Tags/TagsModalContent.js b/frontend/src/Movie/Editor/Tags/TagsModalContent.js
new file mode 100644
index 000000000..ccc1120db
--- /dev/null
+++ b/frontend/src/Movie/Editor/Tags/TagsModalContent.js
@@ -0,0 +1,187 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './TagsModalContent.css';
+
+class TagsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ tags: [],
+ applyTags: 'add'
+ };
+ }
+
+ //
+ // Lifecycle
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ }
+
+ onApplyTagsPress = () => {
+ const {
+ tags,
+ applyTags
+ } = this.state;
+
+ this.props.onApplyTagsPress(tags, applyTags);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ seriesTags,
+ tagList,
+ onModalClose
+ } = this.props;
+
+ const {
+ tags,
+ applyTags
+ } = this.state;
+
+ const applyTagsOptions = [
+ { key: 'add', value: 'Add' },
+ { key: 'remove', value: 'Remove' },
+ { key: 'replace', value: 'Replace' }
+ ];
+
+ return (
+
+
+ Tags
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Apply
+
+
+
+ );
+ }
+}
+
+TagsModalContent.propTypes = {
+ seriesTags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onApplyTagsPress: PropTypes.func.isRequired
+};
+
+export default TagsModalContent;
diff --git a/frontend/src/Movie/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Movie/Editor/Tags/TagsModalContentConnector.js
new file mode 100644
index 000000000..50c780385
--- /dev/null
+++ b/frontend/src/Movie/Editor/Tags/TagsModalContentConnector.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagsModalContent from './TagsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { seriesIds }) => seriesIds,
+ createAllMoviesSelector(),
+ createTagsSelector(),
+ (seriesIds, allMovies, tagList) => {
+ const series = _.intersectionWith(allMovies, seriesIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const seriesTags = _.uniq(_.concat(..._.map(series, 'tags')));
+
+ return {
+ seriesTags,
+ tagList
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onAction() {
+ // Do something
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent);
diff --git a/frontend/src/Movie/History/MovieHistoryModal.js b/frontend/src/Movie/History/MovieHistoryModal.js
new file mode 100644
index 000000000..8f6d8c034
--- /dev/null
+++ b/frontend/src/Movie/History/MovieHistoryModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import MovieHistoryModalContentConnector from './MovieHistoryModalContentConnector';
+
+function MovieHistoryModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+MovieHistoryModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieHistoryModal;
diff --git a/frontend/src/Movie/History/MovieHistoryModalContent.js b/frontend/src/Movie/History/MovieHistoryModalContent.js
new file mode 100644
index 000000000..2635c6c12
--- /dev/null
+++ b/frontend/src/Movie/History/MovieHistoryModalContent.js
@@ -0,0 +1,136 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import MovieHistoryRowConnector from './MovieHistoryRowConnector';
+const columns = [
+ {
+ name: 'eventType',
+ isVisible: true
+ },
+ {
+ name: 'episode',
+ label: 'Episode',
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isVisible: true
+ },
+ {
+ name: 'details',
+ label: 'Details',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: 'Actions',
+ isVisible: true
+ }
+];
+
+class MovieHistoryModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ seasonNumber,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onMarkAsFailedPress,
+ onModalClose
+ } = this.props;
+
+ const fullSeries = seasonNumber == null;
+ const hasItems = !!items.length;
+
+ return (
+
+
+ History
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load history.
+ }
+
+ {
+ isPopulated && !hasItems && !error &&
+ No history.
+ }
+
+ {
+ isPopulated && hasItems && !error &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+MovieHistoryModalContent.propTypes = {
+ seasonNumber: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieHistoryModalContent;
diff --git a/frontend/src/Movie/History/MovieHistoryModalContentConnector.js b/frontend/src/Movie/History/MovieHistoryModalContentConnector.js
new file mode 100644
index 000000000..581069cd5
--- /dev/null
+++ b/frontend/src/Movie/History/MovieHistoryModalContentConnector.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 { fetchMovieHistory, clearMovieHistory, seriesHistoryMarkAsFailed } from 'Store/Actions/movieHistoryActions';
+import MovieHistoryModalContent from './MovieHistoryModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.moviesHistory,
+ (seriesHistory) => {
+ return seriesHistory;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMovieHistory,
+ clearMovieHistory,
+ seriesHistoryMarkAsFailed
+};
+
+class MovieHistoryModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.fetchMovieHistory({
+ seriesId,
+ seasonNumber
+ });
+ }
+
+ componentWillUnmount() {
+ this.props.clearMovieHistory();
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = (historyId) => {
+ const {
+ seriesId,
+ seasonNumber
+ } = this.props;
+
+ this.props.seriesHistoryMarkAsFailed({
+ historyId,
+ seriesId,
+ seasonNumber
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MovieHistoryModalContentConnector.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number,
+ fetchMovieHistory: PropTypes.func.isRequired,
+ clearMovieHistory: PropTypes.func.isRequired,
+ seriesHistoryMarkAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MovieHistoryModalContentConnector);
diff --git a/frontend/src/Movie/History/MovieHistoryRow.css b/frontend/src/Movie/History/MovieHistoryRow.css
new file mode 100644
index 000000000..8c3fb8272
--- /dev/null
+++ b/frontend/src/Movie/History/MovieHistoryRow.css
@@ -0,0 +1,6 @@
+.details,
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 65px;
+}
diff --git a/frontend/src/Movie/History/MovieHistoryRow.js b/frontend/src/Movie/History/MovieHistoryRow.js
new file mode 100644
index 000000000..259052b1a
--- /dev/null
+++ b/frontend/src/Movie/History/MovieHistoryRow.js
@@ -0,0 +1,156 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import MovieQuality from 'Movie/MovieQuality';
+import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
+import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
+import styles from './MovieHistoryRow.css';
+
+function getTitle(eventType) {
+ switch (eventType) {
+ case 'grabbed': return 'Grabbed';
+ case 'seriesFolderImported': return 'Series Folder Imported';
+ case 'downloadFolderImported': return 'Download Folder Imported';
+ case 'downloadFailed': return 'Download Failed';
+ case 'episodeFileDeleted': return 'Episode File Deleted';
+ case 'episodeFileRenamed': return 'Episode File Renamed';
+ default: return 'Unknown';
+ }
+}
+
+class MovieHistoryRow 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
+ // movie,
+ } = this.props;
+
+ const {
+ isMarkAsFailedModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+ {sourceTitle}
+
+
+
+
+
+
+
+
+
+
+ }
+ title={getTitle(eventType)}
+ body={
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+MovieHistoryRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ language: PropTypes.object.isRequired,
+ languageCutoffNotMet: PropTypes.bool.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ fullSeries: PropTypes.bool.isRequired,
+ movie: PropTypes.object.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+export default MovieHistoryRow;
diff --git a/frontend/src/Movie/History/MovieHistoryRowConnector.js b/frontend/src/Movie/History/MovieHistoryRowConnector.js
new file mode 100644
index 000000000..fbabc6321
--- /dev/null
+++ b/frontend/src/Movie/History/MovieHistoryRowConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import MovieHistoryRow from './MovieHistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieSelector(),
+ (movie) => {
+ return {
+ movie
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHistory,
+ markAsFailed
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MovieHistoryRow);
diff --git a/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js
new file mode 100644
index 000000000..605cfd3f7
--- /dev/null
+++ b/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MovieIndexFilterModalConnector from 'Movie/Index/MovieIndexFilterModalConnector';
+
+function MovieIndexFilterMenu(props) {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ isDisabled,
+ onFilterSelect
+ } = props;
+
+ return (
+
+ );
+}
+
+MovieIndexFilterMenu.propTypes = {
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+MovieIndexFilterMenu.defaultProps = {
+ showCustomFilters: false
+};
+
+export default MovieIndexFilterMenu;
diff --git a/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js
new file mode 100644
index 000000000..8ffc0880a
--- /dev/null
+++ b/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js
@@ -0,0 +1,105 @@
+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 MovieIndexSortMenu(props) {
+ const {
+ sortKey,
+ sortDirection,
+ isDisabled,
+ onSortSelect
+ } = props;
+
+ return (
+
+
+
+ Monitored/Status
+
+
+
+ Title
+
+
+
+ Studio
+
+
+
+ Quality Profile
+
+
+
+ Added
+
+
+
+ In Cinemas
+
+
+
+ Physical Release
+
+
+
+ Path
+
+
+
+ );
+}
+
+MovieIndexSortMenu.propTypes = {
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ isDisabled: PropTypes.bool.isRequired,
+ onSortSelect: PropTypes.func.isRequired
+};
+
+export default MovieIndexSortMenu;
diff --git a/frontend/src/Movie/Index/Menus/MovieIndexViewMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexViewMenu.js
new file mode 100644
index 000000000..30cb6bd66
--- /dev/null
+++ b/frontend/src/Movie/Index/Menus/MovieIndexViewMenu.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import ViewMenu from 'Components/Menu/ViewMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import ViewMenuItem from 'Components/Menu/ViewMenuItem';
+
+function MovieIndexViewMenu(props) {
+ const {
+ view,
+ isDisabled,
+ onViewSelect
+ } = props;
+
+ return (
+
+
+
+ Table
+
+
+
+ Posters
+
+
+
+ Overview
+
+
+
+ );
+}
+
+MovieIndexViewMenu.propTypes = {
+ view: PropTypes.string.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onViewSelect: PropTypes.func.isRequired
+};
+
+export default MovieIndexViewMenu;
diff --git a/frontend/src/Movie/Index/MovieIndex.css b/frontend/src/Movie/Index/MovieIndex.css
new file mode 100644
index 000000000..443372a73
--- /dev/null
+++ b/frontend/src/Movie/Index/MovieIndex.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/Movie/Index/MovieIndex.js b/frontend/src/Movie/Index/MovieIndex.js
new file mode 100644
index 000000000..00aad5099
--- /dev/null
+++ b/frontend/src/Movie/Index/MovieIndex.js
@@ -0,0 +1,369 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import { align, icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageJumpBar from 'Components/Page/PageJumpBar';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import NoMovie from 'Movie/NoMovie';
+import MovieIndexTableConnector from './Table/MovieIndexTableConnector';
+import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal';
+import MovieIndexPostersConnector from './Posters/MovieIndexPostersConnector';
+import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal';
+import MovieIndexOverviewsConnector from './Overview/MovieIndexOverviewsConnector';
+import MovieIndexFooter from './MovieIndexFooter';
+import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu';
+import MovieIndexSortMenu from './Menus/MovieIndexSortMenu';
+import MovieIndexViewMenu from './Menus/MovieIndexViewMenu';
+import styles from './MovieIndex.css';
+
+function getViewComponent(view) {
+ if (view === 'posters') {
+ return MovieIndexPostersConnector;
+ }
+
+ if (view === 'overview') {
+ return MovieIndexOverviewsConnector;
+ }
+
+ return MovieIndexTableConnector;
+}
+
+class MovieIndex extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ contentBody: null,
+ jumpBarItems: [],
+ jumpToCharacter: null,
+ isPosterOptionsModalOpen: false,
+ isOverviewOptionsModalOpen: false,
+ isRendered: false
+ };
+ }
+
+ componentDidMount() {
+ this.setJumpBarItems();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ sortKey,
+ sortDirection,
+ scrollTop
+ } = this.props;
+
+ if (
+ hasDifferentItems(prevProps.items, items) ||
+ sortKey !== prevProps.sortKey ||
+ sortDirection !== prevProps.sortDirection
+ ) {
+ this.setJumpBarItems();
+ }
+
+ if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) {
+ this.setState({ jumpToCharacter: null });
+ }
+ }
+
+ //
+ // Control
+
+ setContentBodyRef = (ref) => {
+ this.setState({ contentBody: ref });
+ }
+
+ setJumpBarItems() {
+ const {
+ items,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ // Reset if not sorting by sortTitle
+ if (sortKey !== 'sortTitle') {
+ this.setState({ jumpBarItems: [] });
+ return;
+ }
+
+ const characters = _.reduce(items, (acc, item) => {
+ const firstCharacter = item.sortTitle.charAt(0);
+
+ if (isNaN(firstCharacter)) {
+ acc.push(firstCharacter);
+ } else {
+ acc.push('#');
+ }
+
+ return acc;
+ }, []).sort();
+
+ // Reverse if sorting descending
+ if (sortDirection === sortDirections.DESCENDING) {
+ characters.reverse();
+ }
+
+ this.setState({ jumpBarItems: _.sortedUniq(characters) });
+ }
+
+ //
+ // Listeners
+
+ onPosterOptionsPress = () => {
+ this.setState({ isPosterOptionsModalOpen: true });
+ }
+
+ onPosterOptionsModalClose = () => {
+ this.setState({ isPosterOptionsModalOpen: false });
+ }
+
+ onOverviewOptionsPress = () => {
+ this.setState({ isOverviewOptionsModalOpen: true });
+ }
+
+ onOverviewOptionsModalClose = () => {
+ this.setState({ isOverviewOptionsModalOpen: false });
+ }
+
+ onJumpBarItemPress = (jumpToCharacter) => {
+ this.setState({ jumpToCharacter });
+ }
+
+ onRender = () => {
+ this.setState({ isRendered: true }, () => {
+ const {
+ scrollTop,
+ isSmallScreen
+ } = this.props;
+
+ if (isSmallScreen) {
+ // Seems to result in the view being off by 125px (distance to the top of the page)
+ // document.documentElement.scrollTop = document.body.scrollTop = scrollTop;
+
+ // This works, but then jumps another 1px after scrolling
+ document.documentElement.scrollTop = scrollTop;
+ }
+ });
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.props.onScroll({ scrollTop });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ totalItems,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ view,
+ isRefreshingMovie,
+ isRssSyncExecuting,
+ scrollTop,
+ onSortSelect,
+ onFilterSelect,
+ onViewSelect,
+ onRefreshMoviePress,
+ onRssSyncPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ contentBody,
+ jumpBarItems,
+ jumpToCharacter,
+ isPosterOptionsModalOpen,
+ isOverviewOptionsModalOpen,
+ isRendered
+ } = this.state;
+
+ const ViewComponent = getViewComponent(view);
+ const isLoaded = !!(!error && isPopulated && items.length && contentBody);
+ const hasNoMovie = !totalItems;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {
+ view === 'posters' &&
+
+ }
+
+ {
+ view === 'overview' &&
+
+ }
+
+ {
+ (view === 'posters' || view === 'overview') &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load movies
+ }
+
+ {
+ isLoaded &&
+
+
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+ {
+ isLoaded && !!jumpBarItems.length &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+MovieIndex.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalItems: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ view: PropTypes.string.isRequired,
+ isRefreshingMovie: 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,
+ onRefreshMoviePress: PropTypes.func.isRequired,
+ onRssSyncPress: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default MovieIndex;
diff --git a/frontend/src/Movie/Index/MovieIndexConnector.js b/frontend/src/Movie/Index/MovieIndexConnector.js
new file mode 100644
index 000000000..f420db396
--- /dev/null
+++ b/frontend/src/Movie/Index/MovieIndexConnector.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import dimensions from 'Styles/Variables/dimensions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { fetchMovies } from 'Store/Actions/movieActions';
+import scrollPositions from 'Store/scrollPositions';
+import { setMovieSort, setMovieFilter, setMovieView } from 'Store/Actions/movieIndexActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import withScrollPosition from 'Components/withScrollPosition';
+import MovieIndex from './MovieIndex';
+
+const POSTERS_PADDING = 15;
+const POSTERS_PADDING_SMALL_SCREEN = 5;
+const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding);
+const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen);
+
+// If the scrollTop is greater than zero it needs to be offset
+// by the padding so when it is set initially so it is correct
+// after React Virtualized takes the padding into account.
+
+function getScrollTop(view, scrollTop, isSmallScreen) {
+ if (scrollTop === 0) {
+ return 0;
+ }
+
+ let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING;
+
+ if (view === 'posters') {
+ padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING;
+ }
+
+ return scrollTop + padding;
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('movies', 'movieIndex'),
+ createCommandExecutingSelector(commandNames.REFRESH_MOVIE),
+ createCommandExecutingSelector(commandNames.RSS_SYNC),
+ createDimensionsSelector(),
+ (
+ series,
+ isRefreshingMovie,
+ isRssSyncExecuting,
+ dimensionsState
+ ) => {
+ return {
+ ...series,
+ isRefreshingMovie,
+ isRssSyncExecuting,
+ isSmallScreen: dimensionsState.isSmallScreen
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMovies,
+ setMovieSort,
+ setMovieFilter,
+ setMovieView,
+ executeCommand
+};
+
+class MovieIndexConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ view,
+ scrollTop,
+ isSmallScreen
+ } = props;
+
+ this.state = {
+ scrollTop: getScrollTop(view, scrollTop, isSmallScreen)
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchMovies();
+ }
+
+ //
+ // Listeners
+
+ onSortSelect = (sortKey) => {
+ this.props.setMovieSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setMovieFilter({ selectedFilterKey });
+ }
+
+ onViewSelect = (view) => {
+ // Reset the scroll position before changing the view
+ this.setState({ scrollTop: 0 }, () => {
+ this.props.setMovieView({ view });
+ });
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.setState({
+ scrollTop
+ }, () => {
+ scrollPositions.movieIndex = scrollTop;
+ });
+ }
+
+ onRefreshMoviePress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_MOVIE
+ });
+ }
+
+ onRssSyncPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RSS_SYNC
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MovieIndexConnector.propTypes = {
+ isSmallScreen: PropTypes.bool.isRequired,
+ view: PropTypes.string.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ fetchMovies: PropTypes.func.isRequired,
+ setMovieSort: PropTypes.func.isRequired,
+ setMovieFilter: PropTypes.func.isRequired,
+ setMovieView: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withScrollPosition(
+ connect(createMapStateToProps, mapDispatchToProps)(MovieIndexConnector),
+ 'movieIndex'
+);
diff --git a/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js b/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js
new file mode 100644
index 000000000..c3994ba60
--- /dev/null
+++ b/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setMovieFilter } from 'Store/Actions/movieIndexActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movies.items,
+ (state) => state.movieIndex.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'movieIndex'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setMovieFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/Movie/Index/MovieIndexFooter.css b/frontend/src/Movie/Index/MovieIndexFooter.css
new file mode 100644
index 000000000..ef0a818bf
--- /dev/null
+++ b/frontend/src/Movie/Index/MovieIndexFooter.css
@@ -0,0 +1,72 @@
+.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;
+}
+
+.availNotMonitored {
+ composes: legendItemColor;
+
+ background-color: $darkGray;
+}
+
+.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/Movie/Index/MovieIndexFooter.js b/frontend/src/Movie/Index/MovieIndexFooter.js
new file mode 100644
index 000000000..4e5ba6197
--- /dev/null
+++ b/frontend/src/Movie/Index/MovieIndexFooter.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './MovieIndexFooter.css';
+
+class MovieIndexFooter extends PureComponent {
+
+ render() {
+ const {
+ movies
+ } = this.props;
+
+ const count = movies.length;
+ let movieFiles = 0;
+ let monitored = 0;
+ let totalFileSize = 0;
+
+ movies.forEach((s) => {
+ const { statistics = {} } = s;
+
+ const {
+ sizeOnDisk = 0
+ } = statistics;
+
+ if (s.hasFile) {
+ movieFiles += 1;
+ }
+
+ // if (s.status === 'ended') {
+ // ended++;
+ // } else {
+ // continuing++;
+ // }
+
+ if (s.monitored) {
+ monitored++;
+ }
+
+ totalFileSize += sizeOnDisk;
+ });
+
+ return (
+
+
+
+
+
Downloaded and Monitored
+
+
+
+
+
Downloaded, but not Monitored
+
+
+
+
+
Missing, but not Monitored
+
+
+
+
+
Missing, Monitored and considered Available
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MovieIndexFooter.propTypes = {
+ movies: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default MovieIndexFooter;
diff --git a/frontend/src/Movie/Index/MovieIndexItemConnector.js b/frontend/src/Movie/Index/MovieIndexItemConnector.js
new file mode 100644
index 000000000..422980751
--- /dev/null
+++ b/frontend/src/Movie/Index/MovieIndexItemConnector.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+
+function selectShowSearchAction() {
+ return createSelector(
+ (state) => state.movieIndex,
+ (movieIndex) => {
+ const view = movieIndex.view;
+
+ switch (view) {
+ case 'posters':
+ return movieIndex.posterOptions.showSearchAction;
+ case 'overview':
+ return movieIndex.overviewOptions.showSearchAction;
+ default:
+ return movieIndex.tableOptions.showSearchAction;
+ }
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieSelector(),
+ createQualityProfileSelector(),
+ selectShowSearchAction(),
+ createCommandsSelector(),
+ (
+ movie,
+ qualityProfile,
+ showSearchAction,
+ commands
+ ) => {
+ const isRefreshingMovie = commands.some((command) => {
+ return (
+ command.name === commandNames.REFRESH_MOVIE &&
+ command.body.movieId === movie.id &&
+ isCommandExecuting(command)
+ );
+ });
+
+ const isSearchingMovie = commands.some((command) => {
+ return (
+ command.name === commandNames.MOVIE_SEARCH &&
+ command.body.movieId === movie.id &&
+ isCommandExecuting(command)
+ );
+ });
+
+ return {
+ ...movie,
+ qualityProfile,
+ showSearchAction,
+ isRefreshingMovie,
+ isSearchingMovie
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class MovieIndexItemConnector extends Component {
+
+ //
+ // Listeners
+
+ onRefreshMoviePress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_MOVIE,
+ movieId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.MOVIE_SEARCH,
+ movieIds: [this.props.id]
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ component: ItemComponent,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+MovieIndexItemConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ component: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MovieIndexItemConnector);
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverview.css b/frontend/src/Movie/Index/Overview/MovieIndexOverview.css
new file mode 100644
index 000000000..6311d9be1
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverview.css
@@ -0,0 +1,96 @@
+$hoverScale: 1.05;
+
+.container {
+ &:hover {
+ .content {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+ }
+}
+
+.content {
+ display: flex;
+ flex-grow: 1;
+}
+
+.poster {
+ position: relative;
+}
+
+.posterContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ display: block;
+ color: $defaultColor;
+
+ &:hover {
+ color: $defaultColor;
+ text-decoration: none;
+ }
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.info {
+ display: flex;
+ flex: 1 0 1px;
+ flex-direction: column;
+ overflow: hidden;
+ padding-left: 10px;
+}
+
+.titleRow {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 auto;
+ margin-bottom: 10px;
+ line-height: 32px;
+}
+
+.title {
+ @add-mixin truncate;
+ composes: link;
+
+ flex: 1 0 1px;
+ font-weight: 300;
+ font-size: 30px;
+}
+
+.actions {
+ white-space: nowrap;
+}
+
+.details {
+ display: flex;
+ justify-content: space-between;
+ flex: 1 0 auto;
+}
+
+.overview {
+ composes: link;
+
+ flex: 0 1 1000px;
+ overflow: hidden;
+ min-height: 0;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .overview {
+ display: none;
+ }
+}
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverview.js b/frontend/src/Movie/Index/Overview/MovieIndexOverview.js
new file mode 100644
index 000000000..95f574bec
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverview.js
@@ -0,0 +1,258 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import { icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import fonts from 'Styles/Variables/fonts';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import MoviePoster from 'Movie/MoviePoster';
+import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
+import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
+import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
+import MovieIndexOverviewInfo from './MovieIndexOverviewInfo';
+import styles from './MovieIndexOverview.css';
+
+const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
+const defaultFontSize = parseInt(fonts.defaultFontSize);
+const lineHeight = parseFloat(fonts.lineHeight);
+
+// Hardcoded height beased on line-height of 32 + bottom margin of 10.
+// Less side-effecty than using react-measure.
+const titleRowHeight = 42;
+
+function getContentHeight(rowHeight, isSmallScreen) {
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+
+ return rowHeight - padding;
+}
+
+class MovieIndexOverview extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMoviePress = () => {
+ this.setState({ isEditMovieModalOpen: true });
+ }
+
+ onEditMovieModalClose = () => {
+ this.setState({ isEditMovieModalOpen: false });
+ }
+
+ onDeleteMoviePress = () => {
+ this.setState({
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: true
+ });
+ }
+
+ onDeleteMovieModalClose = () => {
+ this.setState({ isDeleteMovieModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ title,
+ overview,
+ monitored,
+ hasFile,
+ status,
+ titleSlug,
+ images,
+ posterWidth,
+ posterHeight,
+ qualityProfile,
+ overviewOptions,
+ showSearchAction,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ rowHeight,
+ isSmallScreen,
+ isRefreshingMovie,
+ isSearchingMovie,
+ onRefreshMoviePress,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditMovieModalOpen,
+ isDeleteMovieModalOpen
+ } = this.state;
+
+ const link = `/movie/${titleSlug}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ const contentHeight = getContentHeight(rowHeight, isSmallScreen);
+ const overviewHeight = contentHeight - titleRowHeight;
+
+ return (
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MovieIndexOverview.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ overview: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ rowHeight: PropTypes.number.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ overviewOptions: PropTypes.object.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ isRefreshingMovie: PropTypes.bool.isRequired,
+ isSearchingMovie: PropTypes.bool.isRequired,
+ onRefreshMoviePress: PropTypes.func.isRequired
+};
+
+export default MovieIndexOverview;
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.css b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.css
new file mode 100644
index 000000000..5dc53762f
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.css
@@ -0,0 +1,12 @@
+.infos {
+ display: flex;
+ flex: 0 0 250px;
+ flex-direction: column;
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .infos {
+ margin-left: 0;
+ }
+}
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js
new file mode 100644
index 000000000..f69c928ee
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js
@@ -0,0 +1,192 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import MovieIndexOverviewInfoRow from './MovieIndexOverviewInfoRow';
+import styles from './MovieIndexOverviewInfo.css';
+
+const infoRowHeight = parseInt(dimensions.movieIndexOverviewInfoRowHeight);
+
+const rows = [
+ {
+ name: 'monitored',
+ showProp: 'showMonitored',
+ valueProp: 'monitored'
+
+ },
+ {
+ name: 'studio',
+ showProp: 'showStudio',
+ valueProp: 'studio'
+ },
+ {
+ name: 'qualityProfileId',
+ showProp: 'showQualityProfile',
+ valueProp: 'qualityProfileId'
+ },
+ {
+ name: 'added',
+ showProp: 'showAdded',
+ valueProp: 'added'
+ },
+ {
+ name: 'path',
+ showProp: 'showPath',
+ valueProp: 'path'
+ },
+ {
+ name: 'sizeOnDisk',
+ showProp: 'showSizeOnDisk',
+ valueProp: 'sizeOnDisk'
+ }
+];
+
+function isVisible(row, props) {
+ const {
+ name,
+ showProp,
+ valueProp
+ } = row;
+
+ if (props[valueProp] == null) {
+ return false;
+ }
+
+ return props[showProp] || props.sortKey === name;
+}
+
+function getInfoRowProps(row, props) {
+ const { name } = row;
+
+ if (name === 'monitored') {
+ const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored';
+
+ return {
+ title: monitoredText,
+ iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED,
+ label: monitoredText
+ };
+ }
+
+ if (name === 'studio') {
+ return {
+ title: 'Studio',
+ iconName: icons.STUDIO,
+ label: props.studio
+ };
+ }
+
+ if (name === 'qualityProfileId') {
+ return {
+ title: 'Quality Profile',
+ iconName: icons.PROFILE,
+ label: props.qualityProfile.name
+ };
+ }
+
+ if (name === 'added') {
+ const {
+ added,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat
+ } = props;
+
+ return {
+ title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`,
+ iconName: icons.ADD,
+ label: getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ };
+ }
+
+ if (name === 'path') {
+ return {
+ title: 'Path',
+ iconName: icons.FOLDER,
+ label: props.path
+ };
+ }
+
+ if (name === 'sizeOnDisk') {
+ return {
+ title: 'Size on Disk',
+ iconName: icons.DRIVE,
+ label: formatBytes(props.sizeOnDisk)
+ };
+ }
+}
+
+function MovieIndexOverviewInfo(props) {
+ const {
+ height
+ // showRelativeDates,
+ // shortDateFormat,
+ // longDateFormat,
+ // timeFormat
+ } = props;
+
+ let shownRows = 1;
+ const maxRows = Math.floor(height / (infoRowHeight + 4));
+
+ return (
+
+ {
+ rows.map((row) => {
+ if (!isVisible(row, props)) {
+ return null;
+ }
+
+ if (shownRows >= maxRows) {
+ return null;
+ }
+
+ shownRows++;
+
+ const infoRowProps = getInfoRowProps(row, props);
+
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+MovieIndexOverviewInfo.propTypes = {
+ height: PropTypes.number.isRequired,
+ showStudio: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showPath: PropTypes.bool.isRequired,
+ showSizeOnDisk: PropTypes.bool.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ studio: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ added: PropTypes.string,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default MovieIndexOverviewInfo;
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.css b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.css
new file mode 100644
index 000000000..ea94e2ef6
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.css
@@ -0,0 +1,10 @@
+.infoRow {
+ flex: 0 0 $movieIndexOverviewInfoRowHeight;
+ margin: 2px 0;
+}
+
+.icon {
+ margin-right: 5px;
+ width: 25px !important;
+ text-align: center;
+}
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js
new file mode 100644
index 000000000..15e77426d
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import styles from './MovieIndexOverviewInfoRow.css';
+
+function MovieIndexOverviewInfoRow(props) {
+ const {
+ title,
+ iconName,
+ label
+ } = props;
+
+ return (
+
+
+
+ {label}
+
+ );
+}
+
+MovieIndexOverviewInfoRow.propTypes = {
+ title: PropTypes.string,
+ iconName: PropTypes.object.isRequired,
+ label: PropTypes.string.isRequired
+};
+
+export default MovieIndexOverviewInfoRow;
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviews.css b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js
new file mode 100644
index 000000000..71040148f
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js
@@ -0,0 +1,288 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { Grid, WindowScroller } from 'react-virtualized';
+import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import Measure from 'Components/Measure';
+import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector';
+import MovieIndexOverview from './MovieIndexOverview';
+import styles from './MovieIndexOverviews.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+function calculatePosterWidth(posterSize, isSmallScreen) {
+ const maxiumPosterWidth = isSmallScreen ? 152 : 162;
+
+ if (posterSize === 'large') {
+ return maxiumPosterWidth;
+ }
+
+ if (posterSize === 'medium') {
+ return Math.floor(maxiumPosterWidth * 0.75);
+ }
+
+ return Math.floor(maxiumPosterWidth * 0.5);
+}
+
+function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) {
+ const {
+ detailedProgressBar
+ } = overviewOptions;
+
+ const heights = [
+ posterHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return Math.ceil((250 / 170) * posterWidth);
+}
+
+class MovieIndexOverviews extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnCount: 1,
+ posterWidth: 162,
+ posterHeight: 238,
+ rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filters,
+ sortKey,
+ sortDirection,
+ overviewOptions,
+ jumpToCharacter
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+ const overviewOptionsChanged = !_.isMatch(prevProps.overviewOptions, overviewOptions);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.overviewOptions !== overviewOptions ||
+ itemsChanged
+ ) {
+ this.calculateGrid();
+ }
+
+ if (
+ prevProps.filters !== filters ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged ||
+ overviewOptionsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+
+ if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
+ const index = getIndexOfFirstCharacter(items, jumpToCharacter);
+
+ if (index != null) {
+ const {
+ rowHeight
+ } = this.state;
+
+ const scrollTop = rowHeight * index;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+ const {
+ rowHeight
+ } = this.state;
+
+ const index = getIndexOfFirstCharacter(items, character);
+
+ if (index != null) {
+ const scrollTop = rowHeight * index;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+
+ setGridRef = (ref) => {
+ this._grid = ref;
+ }
+
+ calculateGrid = (width = this.state.width, isSmallScreen) => {
+ const {
+ sortKey,
+ overviewOptions
+ } = this.props;
+
+ const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen);
+ const posterHeight = calculatePosterHeight(posterWidth);
+ const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions);
+
+ this.setState({
+ width,
+ posterWidth,
+ posterHeight,
+ rowHeight
+ });
+ }
+
+ cellRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ sortKey,
+ overviewOptions,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ isSmallScreen
+ } = this.props;
+
+ const {
+ posterWidth,
+ posterHeight,
+ rowHeight
+ } = this.state;
+
+ const movie = items[rowIndex];
+
+ if (!movie) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.calculateGrid(width, this.props.isSmallScreen);
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ scrollTop,
+ isSmallScreen,
+ onScroll
+ } = this.props;
+
+ const {
+ width,
+ rowHeight
+ } = this.state;
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+MovieIndexOverviews.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ overviewOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ jumpToCharacter: PropTypes.string,
+ contentBody: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default MovieIndexOverviews;
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js
new file mode 100644
index 000000000..5cef44c78
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import MovieIndexOverviews from './MovieIndexOverviews';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movieIndex.overviewOptions,
+ createClientSideCollectionSelector('movies', 'movieIndex'),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (overviewOptions, movies, uiSettings, dimensions) => {
+ return {
+ overviewOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...movies
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MovieIndexOverviews);
diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js
new file mode 100644
index 000000000..75b38e228
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import MovieIndexOverviewOptionsModalContentConnector from './MovieIndexOverviewOptionsModalContentConnector';
+
+function MovieIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+MovieIndexOverviewOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieIndexOverviewOptionsModal;
diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js
new file mode 100644
index 000000000..01a682892
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js
@@ -0,0 +1,267 @@
+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 MovieIndexOverviewOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showMonitored: props.showMonitored,
+ showStudio: props.showStudio,
+ showQualityProfile: props.showQualityProfile,
+ showAdded: props.showAdded,
+ showPath: props.showPath,
+ showSizeOnDisk: props.showSizeOnDisk,
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showMonitored,
+ showStudio,
+ showQualityProfile,
+ showAdded,
+ showPath,
+ showSizeOnDisk,
+ showSearchAction
+ } = this.props;
+
+ const state = {};
+
+ if (detailedProgressBar !== prevProps.detailedProgressBar) {
+ state.detailedProgressBar = detailedProgressBar;
+ }
+
+ if (size !== prevProps.size) {
+ state.size = size;
+ }
+
+ if (showMonitored !== prevProps.showMonitored) {
+ state.showMonitored = showMonitored;
+ }
+
+ if (showStudio !== prevProps.showStudio) {
+ state.showStudio = showStudio;
+ }
+
+ if (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (showAdded !== prevProps.showAdded) {
+ state.showAdded = showAdded;
+ }
+
+ if (showPath !== prevProps.showPath) {
+ state.showPath = showPath;
+ }
+
+ if (showSizeOnDisk !== prevProps.showSizeOnDisk) {
+ state.showSizeOnDisk = showSizeOnDisk;
+ }
+
+ if (showSearchAction !== prevProps.showSearchAction) {
+ state.showSearchAction = showSearchAction;
+ }
+
+ if (!_.isEmpty(state)) {
+ this.setState(state);
+ }
+ }
+
+ //
+ // Listeners
+
+ onChangeOverviewOption = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onChangeOverviewOption({ [name]: value });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ detailedProgressBar,
+ size,
+ showMonitored,
+ showStudio,
+ showQualityProfile,
+ showAdded,
+ showPath,
+ showSizeOnDisk,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Overview Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+MovieIndexOverviewOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showStudio: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showPath: PropTypes.bool.isRequired,
+ showSizeOnDisk: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ onChangeOverviewOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieIndexOverviewOptionsModalContent;
diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js
new file mode 100644
index 000000000..06e3d7eb8
--- /dev/null
+++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setMovieOverviewOption } from 'Store/Actions/movieIndexActions';
+import MovieIndexOverviewOptionsModalContent from './MovieIndexOverviewOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movieIndex,
+ (movieIndex) => {
+ return movieIndex.overviewOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangeOverviewOption(payload) {
+ dispatch(setMovieOverviewOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexOverviewOptionsModalContent);
diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.css b/frontend/src/Movie/Index/Posters/MovieIndexPoster.css
new file mode 100644
index 000000000..456ebf774
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/MovieIndexPoster.css
@@ -0,0 +1,100 @@
+$hoverScale: 1.05;
+
+.container {
+ padding: 10px;
+}
+
+.content {
+ transition: all 200ms ease-in;
+
+ &:hover {
+ z-index: 2;
+ box-shadow: 0 0 12px $black;
+ transition: all 200ms ease-in;
+
+ .controls {
+ opacity: 0.9;
+ transition: opacity 200ms linear 150ms;
+ }
+ }
+}
+
+.posterContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ position: relative;
+ display: block;
+ background-color: $defaultColor;
+}
+
+.overlayTitle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 5px;
+ width: 100%;
+ height: 100%;
+ color: $offWhite;
+ text-align: center;
+ font-size: 20px;
+}
+
+.nextAiring {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.title {
+ @add-mixin truncate;
+
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.controls {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ z-index: 3;
+ border-radius: 4px;
+ background-color: #4f566f;
+ color: $white;
+ font-size: $smallFontSize;
+ opacity: 0;
+ transition: opacity 0;
+}
+
+.action {
+ composes: button from 'Components/Link/IconButton.css';
+
+ &:hover {
+ color: $radarrYellow;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .container {
+ padding: 5px;
+ }
+}
diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.js b/frontend/src/Movie/Index/Posters/MovieIndexPoster.js
new file mode 100644
index 000000000..64d70adf2
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/MovieIndexPoster.js
@@ -0,0 +1,264 @@
+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 Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import MoviePoster from 'Movie/MoviePoster';
+import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
+import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
+import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
+import MovieIndexPosterInfo from './MovieIndexPosterInfo';
+import styles from './MovieIndexPoster.css';
+
+class MovieIndexPoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPosterError: false,
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMoviePress = () => {
+ this.setState({ isEditMovieModalOpen: true });
+ }
+
+ onEditMovieModalClose = () => {
+ this.setState({ isEditMovieModalOpen: false });
+ }
+
+ onDeleteMoviePress = () => {
+ this.setState({
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: true
+ });
+ }
+
+ onDeleteMovieModalClose = () => {
+ this.setState({ isDeleteMovieModalOpen: false });
+ }
+
+ onPosterLoad = () => {
+ if (this.state.hasPosterError) {
+ this.setState({ hasPosterError: false });
+ }
+ }
+
+ onPosterLoadError = () => {
+ if (!this.state.hasPosterError) {
+ this.setState({ hasPosterError: true });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ title,
+ monitored,
+ hasFile,
+ status,
+ titleSlug,
+ images,
+ posterWidth,
+ posterHeight,
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ qualityProfile,
+ showSearchAction,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isRefreshingMovie,
+ isSearchingMovie,
+ onRefreshMoviePress,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ hasPosterError,
+ isEditMovieModalOpen,
+ isDeleteMovieModalOpen
+ } = this.state;
+
+ const link = `/movie/${titleSlug}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ return (
+
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+ {
+ hasPosterError &&
+
+ {title}
+
+ }
+
+
+
+
+
+ {
+ showTitle &&
+
+ {title}
+
+ }
+
+ {
+ showMonitored &&
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+ }
+
+ {
+ showQualityProfile &&
+
+ {qualityProfile.name}
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MovieIndexPoster.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isRefreshingMovie: PropTypes.bool.isRequired,
+ isSearchingMovie: PropTypes.bool.isRequired,
+ onRefreshMoviePress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+MovieIndexPoster.defaultProps = {
+ statistics: {
+ seasonCount: 0,
+ episodeCount: 0,
+ episodeFileCount: 0,
+ totalEpisodeCount: 0
+ }
+};
+
+export default MovieIndexPoster;
diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.css b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.css
new file mode 100644
index 000000000..aab27d827
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.css
@@ -0,0 +1,5 @@
+.info {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js
new file mode 100644
index 000000000..563061add
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js
@@ -0,0 +1,87 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import styles from './MovieIndexPosterInfo.css';
+
+function MovieIndexPosterInfo(props) {
+ const {
+ studio,
+ qualityProfile,
+ showQualityProfile,
+ added,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (sortKey === 'studio' && studio) {
+ return (
+
+ {studio}
+
+ );
+ }
+
+ if (sortKey === 'qualityProfileId' && !showQualityProfile) {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (sortKey === 'added' && added) {
+ const addedDate = getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: false
+ }
+ );
+
+ return (
+
+ {`Added ${addedDate}`}
+
+ );
+ }
+
+ if (sortKey === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (sortKey === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ return null;
+}
+
+MovieIndexPosterInfo.propTypes = {
+ studio: PropTypes.string,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ added: PropTypes.string,
+ 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 MovieIndexPosterInfo;
diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.css b/frontend/src/Movie/Index/Posters/MovieIndexPosters.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/MovieIndexPosters.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.js b/frontend/src/Movie/Index/Posters/MovieIndexPosters.js
new file mode 100644
index 000000000..101e13aa1
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/MovieIndexPosters.js
@@ -0,0 +1,324 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { Grid, WindowScroller } from 'react-virtualized';
+import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import Measure from 'Components/Measure';
+import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector';
+import MovieIndexPoster from './MovieIndexPoster';
+import styles from './MovieIndexPosters.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+const additionalColumnCount = {
+ small: 3,
+ medium: 2,
+ large: 1
+};
+
+function calculateColumnWidth(width, posterSize, isSmallScreen) {
+ const maxiumColumnWidth = isSmallScreen ? 172 : 182;
+ const columns = Math.floor(width / maxiumColumnWidth);
+ const remainder = width % maxiumColumnWidth;
+
+ if (remainder === 0 && posterSize === 'large') {
+ return maxiumColumnWidth;
+ }
+
+ return Math.floor(width / (columns + additionalColumnCount[posterSize]));
+}
+
+function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) {
+ const {
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile
+ } = posterOptions;
+
+ const nextAiringHeight = 19;
+
+ const heights = [
+ posterHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ nextAiringHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ if (showTitle) {
+ heights.push(19);
+ }
+
+ if (showMonitored) {
+ heights.push(19);
+ }
+
+ if (showQualityProfile) {
+ heights.push(19);
+ }
+
+ switch (sortKey) {
+ case 'studio':
+ case 'added':
+ case 'path':
+ case 'sizeOnDisk':
+ heights.push(19);
+ break;
+ case 'qualityProfileId':
+ if (!showQualityProfile) {
+ heights.push(19);
+ }
+ break;
+ default:
+ // No need to add a height of 0
+ }
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return Math.ceil((250 / 170) * posterWidth);
+}
+
+class MovieIndexPosters extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnWidth: 182,
+ columnCount: 1,
+ posterWidth: 162,
+ posterHeight: 238,
+ rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filters,
+ sortKey,
+ sortDirection,
+ posterOptions,
+ jumpToCharacter
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.posterOptions !== posterOptions ||
+ itemsChanged
+ ) {
+ this.calculateGrid();
+ }
+
+ if (
+ prevProps.filters !== filters ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+
+ if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
+ const index = getIndexOfFirstCharacter(items, jumpToCharacter);
+
+ if (index != null) {
+ const {
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const row = Math.floor(index / columnCount);
+ const scrollTop = rowHeight * row;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+ }
+
+ //
+ // Control
+
+ setGridRef = (ref) => {
+ this._grid = ref;
+ }
+
+ calculateGrid = (width = this.state.width, isSmallScreen) => {
+ const {
+ sortKey,
+ posterOptions
+ } = this.props;
+
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+ const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen);
+ const columnCount = Math.max(Math.floor(width / columnWidth), 1);
+ const posterWidth = columnWidth - padding;
+ const posterHeight = calculatePosterHeight(posterWidth);
+ const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions);
+
+ this.setState({
+ width,
+ columnWidth,
+ columnCount,
+ posterWidth,
+ posterHeight,
+ rowHeight
+ });
+ }
+
+ cellRenderer = ({ key, rowIndex, columnIndex, style }) => {
+ const {
+ items,
+ sortKey,
+ posterOptions,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = this.props;
+
+ const {
+ posterWidth,
+ posterHeight,
+ columnCount
+ } = this.state;
+
+ const {
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile
+ } = posterOptions;
+
+ const movie = items[rowIndex * columnCount + columnIndex];
+
+ if (!movie) {
+ 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 (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+MovieIndexPosters.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ posterOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ jumpToCharacter: PropTypes.string,
+ contentBody: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default MovieIndexPosters;
diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js b/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js
new file mode 100644
index 000000000..222dc359a
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import MovieIndexPosters from './MovieIndexPosters';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movieIndex.posterOptions,
+ createClientSideCollectionSelector('movies', 'movieIndex'),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (posterOptions, movies, uiSettings, dimensions) => {
+ return {
+ posterOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...movies
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MovieIndexPosters);
diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js
new file mode 100644
index 000000000..a9aeaaed7
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import MovieIndexPosterOptionsModalContentConnector from './MovieIndexPosterOptionsModalContentConnector';
+
+function MovieIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+MovieIndexPosterOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieIndexPosterOptionsModal;
diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js
new file mode 100644
index 000000000..9aa9d5160
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js
@@ -0,0 +1,213 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+const posterSizeOptions = [
+ { key: 'small', value: 'Small' },
+ { key: 'medium', value: 'Medium' },
+ { key: 'large', value: 'Large' }
+];
+
+class MovieIndexPosterOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showTitle: props.showTitle,
+ showMonitored: props.showMonitored,
+ showQualityProfile: props.showQualityProfile,
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ showSearchAction
+ } = this.props;
+
+ const state = {};
+
+ if (detailedProgressBar !== prevProps.detailedProgressBar) {
+ state.detailedProgressBar = detailedProgressBar;
+ }
+
+ if (size !== prevProps.size) {
+ state.size = size;
+ }
+
+ if (showTitle !== prevProps.showTitle) {
+ state.showTitle = showTitle;
+ }
+
+ if (showMonitored !== prevProps.showMonitored) {
+ state.showMonitored = showMonitored;
+ }
+
+ if (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (showSearchAction !== prevProps.showSearchAction) {
+ state.showSearchAction = showSearchAction;
+ }
+
+ if (!_.isEmpty(state)) {
+ this.setState(state);
+ }
+ }
+
+ //
+ // Listeners
+
+ onChangePosterOption = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onChangePosterOption({ [name]: value });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Poster Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+MovieIndexPosterOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ onChangePosterOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieIndexPosterOptionsModalContent;
diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js
new file mode 100644
index 000000000..c8b9a2a88
--- /dev/null
+++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setMoviePosterOption } from 'Store/Actions/movieIndexActions';
+import MovieIndexPosterOptionsModalContent from './MovieIndexPosterOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movieIndex,
+ (movieIndex) => {
+ return movieIndex.posterOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangePosterOption(payload) {
+ dispatch(setMoviePosterOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexPosterOptionsModalContent);
diff --git a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css
new file mode 100644
index 000000000..dbf3499ab
--- /dev/null
+++ b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.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/Movie/Index/ProgressBar/MovieIndexProgressBar.js b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js
new file mode 100644
index 000000000..babeece19
--- /dev/null
+++ b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getProgressBarKind from 'Utilities/Movie/getProgressBarKind';
+import { sizes } from 'Helpers/Props';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './MovieIndexProgressBar.css';
+
+function MovieIndexProgressBar(props) {
+ const {
+ monitored,
+ status,
+ hasFile,
+ posterWidth,
+ detailedProgressBar
+ } = props;
+
+ const progress = 100;
+
+ return (
+
+ );
+}
+
+MovieIndexProgressBar.propTypes = {
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired
+};
+
+export default MovieIndexProgressBar;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js b/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js
new file mode 100644
index 000000000..92cead8a0
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexActionsCell.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 EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
+import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
+
+class MovieIndexActionsCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMoviePress = () => {
+ this.setState({ isEditMovieModalOpen: true });
+ }
+
+ onEditMovieModalClose = () => {
+ this.setState({ isEditMovieModalOpen: false });
+ }
+
+ onDeleteMoviePress = () => {
+ this.setState({
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: true
+ });
+ }
+
+ onDeleteMovieModalClose = () => {
+ this.setState({ isDeleteMovieModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ isRefreshingMovie,
+ onRefreshMoviePress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditMovieModalOpen,
+ isDeleteMovieModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MovieIndexActionsCell.propTypes = {
+ id: PropTypes.number.isRequired,
+ isRefreshingMovie: PropTypes.bool.isRequired,
+ onRefreshMoviePress: PropTypes.func.isRequired
+};
+
+export default MovieIndexActionsCell;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeader.css b/frontend/src/Movie/Index/Table/MovieIndexHeader.css
new file mode 100644
index 000000000..d0a559a07
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexHeader.css
@@ -0,0 +1,67 @@
+.status {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortTitle {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 4 0 110px;
+}
+
+.studio {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 2 0 90px;
+}
+
+.qualityProfileId {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 125px;
+}
+
+.added,
+.inCinemas,
+.genres {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 180px;
+}
+
+.certification {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 100px;
+}
+
+.path {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 120px;
+}
+
+.ratings {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 80px;
+}
+
+.tags {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 60px;
+}
+
+.actions {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 90px;
+}
diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeader.js b/frontend/src/Movie/Index/Table/MovieIndexHeader.js
new file mode 100644
index 000000000..32df50861
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexHeader.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
+import MovieIndexTableOptionsConnector from './MovieIndexTableOptionsConnector';
+import styles from './MovieIndexHeader.css';
+
+class MovieIndexHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isTableOptionsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onTableOptionsPress = () => {
+ this.setState({ isTableOptionsModalOpen: true });
+ }
+
+ onTableOptionsModalClose = () => {
+ this.setState({ isTableOptionsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ showSearchAction,
+ columns,
+ onTableOptionChange,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ label,
+ isSortable,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {label}
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+MovieIndexHeader.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default MovieIndexHeader;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js b/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js
new file mode 100644
index 000000000..d56915879
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { setMovieTableOption } from 'Store/Actions/movieIndexActions';
+import MovieIndexHeader from './MovieIndexHeader';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setMovieTableOption(payload));
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(MovieIndexHeader);
diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.css b/frontend/src/Movie/Index/Table/MovieIndexRow.css
new file mode 100644
index 000000000..b7702acd3
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexRow.css
@@ -0,0 +1,73 @@
+.status {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortTitle {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 4 0 110px;
+}
+
+.studio {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 2 0 90px;
+}
+
+.qualityProfileId {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 125px;
+}
+
+.added,
+.inCinemas,
+.genres {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 180px;
+}
+
+.certification {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 100px;
+}
+
+.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 120px;
+}
+
+.ratings {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 80px;
+}
+
+.tags {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 60px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 90px;
+}
+
+.checkInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.js b/frontend/src/Movie/Index/Table/MovieIndexRow.js
new file mode 100644
index 000000000..715ae88e1
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexRow.js
@@ -0,0 +1,333 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+// import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import { icons } from 'Helpers/Props';
+import HeartRating from 'Components/HeartRating';
+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 MovieTitleLink from 'Movie/MovieTitleLink';
+import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
+import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
+import MovieStatusCell from './MovieStatusCell';
+import styles from './MovieIndexRow.css';
+
+class MovieIndexRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: false
+ };
+ }
+
+ onEditMoviePress = () => {
+ this.setState({ isEditMovieModalOpen: true });
+ }
+
+ onEditMovieModalClose = () => {
+ this.setState({ isEditMovieModalOpen: false });
+ }
+
+ onDeleteMoviePress = () => {
+ this.setState({
+ isEditMovieModalOpen: false,
+ isDeleteMovieModalOpen: true
+ });
+ }
+
+ onDeleteMovieModalClose = () => {
+ this.setState({ isDeleteMovieModalOpen: false });
+ }
+
+ onUseSceneNumberingChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ monitored,
+ status,
+ title,
+ titleSlug,
+ studio,
+ qualityProfile,
+ added,
+ inCinemas,
+ physicalRelease,
+ path,
+ genres,
+ ratings,
+ certification,
+ tags,
+ showSearchAction,
+ columns,
+ isRefreshingMovie,
+ isSearchingMovie,
+ onRefreshMoviePress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ isEditMovieModalOpen,
+ isDeleteMovieModalOpen
+ } = this.state;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'status') {
+ return (
+
+ );
+ }
+
+ if (name === 'sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'studio') {
+ return (
+
+ {studio}
+
+ );
+ }
+
+ if (name === 'qualityProfileId') {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (name === 'added') {
+ return (
+
+ );
+ }
+
+ if (name === 'inCinemas') {
+ return (
+
+ );
+ }
+
+ if (name === 'physicalRelease') {
+ return (
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (name === 'genres') {
+ const joinedGenres = genres.join(', ');
+
+ return (
+
+
+ {joinedGenres}
+
+
+ );
+ }
+
+ if (name === 'ratings') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'certification') {
+ return (
+
+ {certification}
+
+ );
+ }
+
+ if (name === 'tags') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+MovieIndexRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ studio: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ added: PropTypes.string,
+ inCinemas: PropTypes.string,
+ physicalRelease: PropTypes.string,
+ path: PropTypes.string.isRequired,
+ genres: PropTypes.arrayOf(PropTypes.string).isRequired,
+ ratings: PropTypes.object.isRequired,
+ certification: PropTypes.string,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isRefreshingMovie: PropTypes.bool.isRequired,
+ isSearchingMovie: PropTypes.bool.isRequired,
+ onRefreshMoviePress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+MovieIndexRow.defaultProps = {
+ genres: [],
+ tags: []
+};
+
+export default MovieIndexRow;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.css b/frontend/src/Movie/Index/Table/MovieIndexTable.css
new file mode 100644
index 000000000..e46160a96
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexTable.css
@@ -0,0 +1,5 @@
+.tableContainer {
+ composes: tableContainer from 'Components/Table/VirtualTable.css';
+
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.js b/frontend/src/Movie/Index/Table/MovieIndexTable.js
new file mode 100644
index 000000000..da5260d9b
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexTable.js
@@ -0,0 +1,126 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
+import { sortDirections } from 'Helpers/Props';
+import VirtualTable from 'Components/Table/VirtualTable';
+import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector';
+import MovieIndexHeaderConnector from './MovieIndexHeaderConnector';
+import MovieIndexRow from './MovieIndexRow';
+import styles from './MovieIndexTable.css';
+
+class MovieIndexTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ scrollIndex: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const jumpToCharacter = this.props.jumpToCharacter;
+
+ if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
+ const items = this.props.items;
+
+ const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter);
+
+ if (scrollIndex != null) {
+ this.setState({ scrollIndex });
+ }
+ } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) {
+ this.setState({ scrollIndex: null });
+ }
+ }
+
+ //
+ // Control
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ columns
+ } = this.props;
+
+ const movie = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ columns,
+ filters,
+ sortKey,
+ sortDirection,
+ isSmallScreen,
+ scrollTop,
+ contentBody,
+ onSortPress,
+ onRender,
+ onScroll
+ } = this.props;
+
+ return (
+
+ }
+ columns={columns}
+ filters={filters}
+ sortKey={sortKey}
+ sortDirection={sortDirection}
+ onRender={onRender}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+MovieIndexTable.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ scrollTop: PropTypes.number.isRequired,
+ jumpToCharacter: PropTypes.string,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default MovieIndexTable;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js b/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js
new file mode 100644
index 000000000..52ff38ca3
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setMovieSort } from 'Store/Actions/movieIndexActions';
+import MovieIndexTable from './MovieIndexTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ createClientSideCollectionSelector('movies', 'movieIndex'),
+ (dimensions, movies) => {
+ return {
+ isSmallScreen: dimensions.isSmallScreen,
+ ...movies
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSortPress(sortKey) {
+ dispatch(setMovieSort({ sortKey }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexTable);
diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js b/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js
new file mode 100644
index 000000000..169d390f3
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+class MovieIndexTableOptions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const { showSearchAction } = this.props;
+
+ if (showSearchAction !== prevProps.showSearchAction) {
+ this.setState({
+ showSearchAction
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onTableOptionChange = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onTableOptionChange({
+ tableOptions: {
+ ...this.state,
+ [name]: value
+ }
+ });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ showSearchAction
+ } = this.state;
+
+ return (
+
+ Show Search
+
+
+
+ );
+ }
+}
+
+MovieIndexTableOptions.propTypes = {
+ showSearchAction: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default MovieIndexTableOptions;
diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js b/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js
new file mode 100644
index 000000000..018a98678
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import MovieIndexTableOptions from './MovieIndexTableOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movieIndex.tableOptions,
+ (tableOptions) => {
+ return tableOptions;
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MovieIndexTableOptions);
diff --git a/frontend/src/Movie/Index/Table/MovieStatusCell.css b/frontend/src/Movie/Index/Table/MovieStatusCell.css
new file mode 100644
index 000000000..a7681e36c
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieStatusCell.css
@@ -0,0 +1,9 @@
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 60px;
+}
+
+.statusIcon {
+ width: 20px !important;
+}
diff --git a/frontend/src/Movie/Index/Table/MovieStatusCell.js b/frontend/src/Movie/Index/Table/MovieStatusCell.js
new file mode 100644
index 000000000..a3875dc1c
--- /dev/null
+++ b/frontend/src/Movie/Index/Table/MovieStatusCell.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 './MovieStatusCell.css';
+
+function MovieStatusCell(props) {
+ const {
+ className,
+ monitored,
+ status,
+ component: Component,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+}
+
+MovieStatusCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ component: PropTypes.func
+};
+
+MovieStatusCell.defaultProps = {
+ className: styles.status,
+ component: VirtualTableRowCell
+};
+
+export default MovieStatusCell;
diff --git a/frontend/src/Movie/MoveSeries/MoveSeriesModal.css b/frontend/src/Movie/MoveSeries/MoveSeriesModal.css
new file mode 100644
index 000000000..11f33bef2
--- /dev/null
+++ b/frontend/src/Movie/MoveSeries/MoveSeriesModal.css
@@ -0,0 +1,5 @@
+.doNotMoveButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Movie/MoveSeries/MoveSeriesModal.js b/frontend/src/Movie/MoveSeries/MoveSeriesModal.js
new file mode 100644
index 000000000..6d767f22f
--- /dev/null
+++ b/frontend/src/Movie/MoveSeries/MoveSeriesModal.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './MoveSeriesModal.css';
+
+function MoveSeriesModal(props) {
+ const {
+ originalPath,
+ destinationPath,
+ destinationRootFolder,
+ isOpen,
+ onSavePress,
+ onMoveSeriesPress
+ } = props;
+
+ if (
+ isOpen &&
+ !originalPath &&
+ !destinationPath &&
+ !destinationRootFolder
+ ) {
+ console.error('orginalPath and destinationPath OR destinationRootFolder must be provided');
+ }
+
+ return (
+
+
+
+ Move Files
+
+
+
+ {
+ destinationRootFolder ?
+ `Would you like to move the series folders to '${destinationRootFolder}'?` :
+ `Would you like to move the series files from '${originalPath}' to '${destinationPath}'?`
+ }
+
+
+
+
+ No, I'll Move the Files Myself
+
+
+
+ Yes, Move the Files
+
+
+
+
+ );
+}
+
+MoveSeriesModal.propTypes = {
+ originalPath: PropTypes.string,
+ destinationPath: PropTypes.string,
+ destinationRootFolder: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onMoveSeriesPress: PropTypes.func.isRequired
+};
+
+export default MoveSeriesModal;
diff --git a/frontend/src/Movie/MoviePoster.js b/frontend/src/Movie/MoviePoster.js
new file mode 100644
index 000000000..17c86264c
--- /dev/null
+++ b/frontend/src/Movie/MoviePoster.js
@@ -0,0 +1,195 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAKHklEQVR42u2c25bbKhJATTmUPAZKPerBjTMo0fn/n5wHSYBkXUDCnXPWwEPaneVIO0XdKAouzT9kXApoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gP4DQLXW+vF4GGMeD6211n87UK2NsW33OlprTB7eSw5I220PmwH2JKh+7EGOoj3Lejkly0hKx/pHQLVpu9RhzbeDHsEc1PU7QbXpDo/WfB/oQWmeUoADoMZ2Z4fV7wfV5zG7ruvMu0FPzvpxoV7+hDiPCDUJVLddzmHfBfqZlzONNAG0VrXNy/lB7wCtifKSth+KKD8oEREpshk5JRFRnRm0VkREJLKR2kYQERF9ZAUdHkokM5EO8iQiQRlBiSG552Yhdf91wfDf2UBrkj+Q6nyk9mPklAzj9PQSqZ/qR0aZtrWXZ0UUZfuXKL9ERBKzkdray/Nf/YcsoIrmpOcsynMKqMZHngfVn4MHJeVJz/jTYN7RORN1GlTb7tM5Eqw86fMg55Pc47jjpGY3698DtV3Xfgo1kjqZEulD4tTKafrVO+cP23WPU6Bm6vSC2SfVJK/w2p8fntPPu6ht13WtPgE6SK0dSeuQlMSn/ZWW1EvHWYGYOxF7AtTOAzMpHpKKRwqm8jpZMfHq7MxhUD+3bXMb9QmwdwIDqrYx6bS1WnhMuoWcrX/JQdBw5RHMPgQyJRKiee6w/rLmM8RclueOSC9RAp1YlPyBKnirEoK0sXZVlk9NQrh/URMhm9mRG/oQ6Mz/tKGehqRESgjVaGPsRLSttU+jGxJCBt+Vap1zy542QJ9/zYTjPL/iWAmasd4EUdNoYx7m68sYrT8/ahJTSlIVIrq/kc18HvQB0AWH3jhBIuN3ehlSSiGEFEoKIYWQcv4FVQGwSjlP3MavS9dBl2Lk5xiiGICPp/EDOQBzetMs6LVOBl2MkL/G5BkAYEmmm0NVAAAuIi1xrov0EmfyLqVQnhThni5Pz7mSgOlE0JXqTaulI0WAW4o8kfGAUz7SKlJroGuxsXUxiXO8Tn3/jjwZIvfypLUXJIKuppvGp+eAHKp4TkDwGaj4ufYCnQS6kWz6xBcQgVWRdsT4FcKMfjXqPpNAN1JN4xRT8CtCnEXdGCD6zI6E3citU0A3lkStEymJKwPGZQSoRBbIk+THRg6TArq5zDA+wxDAcMZZKymlVK82D2Ga9zO5En0A1AYUwYKiF5XAYQgxllbGZCD4FrXJ5d1Lqop2XauDd05EJypkDBgHYIxxrNaU4ra9ZaHjQTdX7a0Vaun1Aq8AAAA4/MGwWvzilimtzv0leea7rq0XRKVuwELQ4aNY4my+CbTTC69HAHgFDVx8sBIxB/YgLinx0/lkscgJiAgAHJEDICICcFyQqdirB0WD7lUWLKlXTgQERARE4IjAAThH5K+zv1+40rGguz0izUxJb6E4e9l6HeBzge7uVz1ygc6VVKBjG37wAHSeuIjdUpCJBd2tJ3yJeWY06OQg10GwAzuIN4Hu1+nMZOrltRclH7S0l2ivrr2Upzq6W7G02UCn1lQxBOQcOCBw4JwDAFwHSg4I04LF/vZfTlA5WWP04R0QARAAOSBcERGG31k493LfBNp8oB9yakq97cxCqDMohpO4tF9VywfaBDISzr4XItNAG/6/IkrV2UDb/wSgdzayIf+7gXYBaH1ng29yUdP/gtjHU+lz05jibz6J6kBEzoHy8AcfP3PEScD/VtBJaKogiJpjZOKBDuDE5X8r6K9dUJyA/j0kegevk5MQ6gIT+3NWryfuiY/JKALiFQA4R47IB2qc+tFvBW3UJDL1wkNAuCLnCPw6ps8c+VRFSex3T70pMlEfQgHh2ufPCFfoQ+iop6JOikzvSkrEIFG4YuDjPSibJCUyX1Kyn48+J6AKt0Mou6WtRBbrZMdAzbRmI9jo7H0kxd5FcYRplkdK7YKabEsRI2aFJeS9jY/pXv+p/3Cdre7Ef78NtJ0v7CUHQOQ4WHmf3l9HhzUv6Ox6fJ1tudzMl8CCuwwKAQBYYFWUvArVuQoQr+t6EnwlhOJrBXLPmtpsJR0jlkpki6CvnKT2KiXxJZ0dl/x7qfZECoE5VzrqwWLdfC8tiS+S7VjTZGk3FSrvSRGBM0Bc/p78sMkqeqSQ+9uKtVK9QAQGDBgDfNmAjq6SJYBul8b1pMo9V8D7XVTVXcwoJ1u82wlUSml8M8EJbV4s7TPVS9u17B5bw0/ZbNice7/RRAoZrJS/Z3bGryHp7Zlp+2Zr7n/7wrhEhvwSsXMrGOdhbrLVhWjTthjX5+Z584L6wafZ+wYpcM6idu5M2qat2d8LVQjIGaoYUKoY8nA7ct1Vp23ars+9EQEnxnIS3QEhIJUm8bTDZa/b7WUn1PW9AiCP5uzzlnD11MaXxQ+0anSurfKlSrdPOqk+r3RApPeULJ8Isr6PGID3IbJe959T5yqmK1Kb0qmx0U60KNJxmdwvN+W+q59F2LBg1sRv1m93ki11JXlDWszg9i0qUBelEwS6BfoqUqP8ImmZUykphRJCSKnUwhfuWAX9Gia+kWyz29Gu7IXUhFxUYjrPSgpxE5Lq/pDKR01S3MR8H1pJuju/r+SjjRXoJuhjbXMJ5+0ZStwENfpp+9H2P/pex9scVnjS2ZaTPdqRa5c7NJBNXy0ENcYud5Dap/mUNznbPxtnQ00TPn0UNHzKw8uTyWnvaGPtViZs22czTU/HjlxFMlyW2OPN2G5mfn+5PlAEFfaQyK+IJufWPijUAAxmX0e1OO/14VsnTznae6ifkqIPtLaGwjYd13AgHak5AzqkewEnHsLsSfzCpb77bkL5tdVBFnsEw/T27uwojEbJ526tDvR0fFKtpN6d+IjTN6brHtJHeOfyqTlyrCU4g+E9v1J62+LjzjNZV2NUXp5KHTrT0nWtVguzo/TuQeZ9UE2vJ1rUoFdHhlHSxVOvs1nO3PW5csgpjnN2nfGezulpplOMpKgO4qYSp07Zt0/n/hGpJlKZDgc2TdM/03m+R3dqtDOZRp0KjjxpK4GP+e5pzq7rjJfpj6wnbRvya50MnF3nZl8BNjlBGz/vpssx/Ow3eUHHc+syD+e4A6SiD9gn3FhARErl4uzXNapu3gDa1IrycXadIXrL1QpN09Q5ORPv/0i7pyQvqH4faM4bVRKvfkm+SyeTUJMvU0q/nSiLUNOvJzpy39Ppi3+OXPh06GIq/fzWWT8Oegb16F1vh295O3Z72uG7087cm6cT7/z66wTm2ZsIU8RqT93vd/puRx0n1/O3O+a4LVM/NmFtlvsyc90/qrUxz5fT4MZku4Q0/42uWue+I/VNoG8aBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpA/99B/wd7kHH8CSaCpAAAAABJRU5ErkJggg==';
+
+function 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 MoviePoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.ceil(window.devicePixelRatio);
+
+ const {
+ images,
+ size
+ } = props;
+
+ const poster = findPoster(images);
+
+ this.state = {
+ pixelRatio,
+ poster,
+ posterUrl: getPosterUrl(poster, pixelRatio * size),
+ isLoaded: false,
+ hasError: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.state.posterUrl && this.props.onError) {
+ this.props.onError();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ images,
+ size,
+ onError
+ } = this.props;
+
+ const {
+ poster,
+ pixelRatio
+ } = this.state;
+
+ const nextPoster = findPoster(images);
+
+ if (nextPoster && (!poster || nextPoster.url !== poster.url)) {
+ this.setState({
+ poster: nextPoster,
+ posterUrl: getPosterUrl(nextPoster, pixelRatio * size),
+ hasError: false
+ // Don't reset isLoaded, as we want to immediately try to
+ // show the new image, whether an image was shown previously
+ // or the placeholder was shown.
+ });
+ } else if (!nextPoster && poster) {
+ this.setState({
+ poster: nextPoster,
+ posterUrl: posterPlaceholder,
+ hasError: false
+ });
+
+ if (onError) {
+ onError();
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({
+ hasError: true
+ });
+
+ if (this.props.onError) {
+ this.props.onError();
+ }
+ }
+
+ onLoad = () => {
+ this.setState({
+ isLoaded: true,
+ hasError: false
+ });
+
+ if (this.props.onLoad) {
+ this.props.onLoad();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ posterUrl,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !posterUrl) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+MoviePoster.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,
+ onError: PropTypes.func,
+ onLoad: PropTypes.func
+};
+
+MoviePoster.defaultProps = {
+ size: 250,
+ lazy: true,
+ overflow: false
+};
+
+export default MoviePoster;
diff --git a/frontend/src/Movie/MovieQuality.js b/frontend/src/Movie/MovieQuality.js
new file mode 100644
index 000000000..105787c96
--- /dev/null
+++ b/frontend/src/Movie/MovieQuality.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function getTooltip(title, quality, size) {
+ const revision = quality.revision;
+
+ if (revision.real && revision.real > 0) {
+ title += ' [REAL]';
+ }
+
+ if (revision.version && revision.version > 1) {
+ title += ' [PROPER]';
+ }
+
+ if (size) {
+ title += ` - ${formatBytes(size)}`;
+ }
+
+ return title;
+}
+
+function MovieQuality(props) {
+ const {
+ className,
+ title,
+ quality,
+ size,
+ isCutoffNotMet
+ } = props;
+
+ return (
+
+ {quality.quality.name}
+
+ );
+}
+
+MovieQuality.propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string,
+ quality: PropTypes.object.isRequired,
+ size: PropTypes.number,
+ isCutoffNotMet: PropTypes.bool
+};
+
+MovieQuality.defaultProps = {
+ title: ''
+};
+
+export default MovieQuality;
diff --git a/frontend/src/Movie/MovieTitleLink.js b/frontend/src/Movie/MovieTitleLink.js
new file mode 100644
index 000000000..5dccf64b5
--- /dev/null
+++ b/frontend/src/Movie/MovieTitleLink.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import Link from 'Components/Link/Link';
+
+class MovieTitleLink extends PureComponent {
+
+ render() {
+ const {
+ titleSlug,
+ title
+ } = this.props;
+
+ const link = `/movie/${titleSlug}`;
+
+ return (
+
+ {title}
+
+ );
+ }
+}
+
+MovieTitleLink.propTypes = {
+ titleSlug: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired
+};
+
+export default MovieTitleLink;
diff --git a/frontend/src/Movie/NoMovie.css b/frontend/src/Movie/NoMovie.css
new file mode 100644
index 000000000..38a01f391
--- /dev/null
+++ b/frontend/src/Movie/NoMovie.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/Movie/NoMovie.js b/frontend/src/Movie/NoMovie.js
new file mode 100644
index 000000000..0e768530f
--- /dev/null
+++ b/frontend/src/Movie/NoMovie.js
@@ -0,0 +1,51 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import styles from './NoMovie.css';
+
+function NoMovie(props) {
+ const { totalItems } = props;
+
+ if (totalItems > 0) {
+ return (
+
+
+ All movies are hidden due to the applied filter.
+
+
+ );
+ }
+
+ return (
+
+
+ No movies found, to get started you'll want to add a new movie or import some existing ones.
+
+
+
+
+ Import Existing Movies
+
+
+
+
+
+ Add New Movie
+
+
+
+ );
+}
+
+NoMovie.propTypes = {
+ totalItems: PropTypes.number.isRequired
+};
+
+export default NoMovie;
diff --git a/frontend/src/Movie/Search/SeasonInteractiveSearchModal.js b/frontend/src/Movie/Search/SeasonInteractiveSearchModal.js
new file mode 100644
index 000000000..7973affba
--- /dev/null
+++ b/frontend/src/Movie/Search/SeasonInteractiveSearchModal.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import SeasonInteractiveSearchModalContent from './SeasonInteractiveSearchModalContent';
+
+function SeasonInteractiveSearchModal(props) {
+ const {
+ isOpen,
+ seriesId,
+ seasonNumber,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+SeasonInteractiveSearchModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeasonInteractiveSearchModal;
diff --git a/frontend/src/Movie/Search/SeasonInteractiveSearchModalConnector.js b/frontend/src/Movie/Search/SeasonInteractiveSearchModalConnector.js
new file mode 100644
index 000000000..e270ebdec
--- /dev/null
+++ b/frontend/src/Movie/Search/SeasonInteractiveSearchModalConnector.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
+import SeasonInteractiveSearchModal from './SeasonInteractiveSearchModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ dispatch(cancelFetchReleases());
+ dispatch(clearReleases());
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(SeasonInteractiveSearchModal);
diff --git a/frontend/src/Movie/Search/SeasonInteractiveSearchModalContent.js b/frontend/src/Movie/Search/SeasonInteractiveSearchModalContent.js
new file mode 100644
index 000000000..51ca9702c
--- /dev/null
+++ b/frontend/src/Movie/Search/SeasonInteractiveSearchModalContent.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
+
+function SeasonInteractiveSearchModalContent(props) {
+ const {
+ seriesId,
+ seasonNumber,
+ onModalClose
+ } = props;
+
+ return (
+
+
+ Interactive Search
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+SeasonInteractiveSearchModalContent.propTypes = {
+ seriesId: PropTypes.number.isRequired,
+ seasonNumber: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SeasonInteractiveSearchModalContent;
diff --git a/frontend/src/Movie/movieEntities.js b/frontend/src/Movie/movieEntities.js
new file mode 100644
index 000000000..32b276a4b
--- /dev/null
+++ b/frontend/src/Movie/movieEntities.js
@@ -0,0 +1,9 @@
+export const CALENDAR = 'calendar';
+export const MOVIES = 'movies';
+export const INTERACTIVE_IMPORT = 'interactiveImport.movies';
+
+export default {
+ CALENDAR,
+ MOVIES,
+ INTERACTIVE_IMPORT
+};
diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModal.js b/frontend/src/MovieFile/Editor/MovieFileEditorModal.js
new file mode 100644
index 000000000..eae5a7b6b
--- /dev/null
+++ b/frontend/src/MovieFile/Editor/MovieFileEditorModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import MovieFileEditorModalContentConnector from './MovieFileEditorModalContentConnector';
+
+function MovieFileEditorModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+MovieFileEditorModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieFileEditorModal;
diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.css b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.css
new file mode 100644
index 000000000..49e946826
--- /dev/null
+++ b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.css
@@ -0,0 +1,8 @@
+.actions {
+ display: flex;
+ margin-right: auto;
+}
+
+.selectInput {
+ margin-left: 10px;
+}
diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.js b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.js
new file mode 100644
index 000000000..084d85663
--- /dev/null
+++ b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.js
@@ -0,0 +1,280 @@
+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 MovieFileEditorRow from './MovieFileEditorRow';
+import styles from './MovieFileEditorModalContent.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 MovieFileEditorModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.items !== this.props.items) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ const selectedIds = getSelectedIds(this.state.selectedState);
+
+ return selectedIds.reduce((acc, id) => {
+ const matchingItem = this.props.items.find((item) => item.id === id);
+
+ if (matchingItem && !acc.includes(matchingItem.episodeFileId)) {
+ acc.push(matchingItem.episodeFileId);
+ }
+
+ return acc;
+ }, []);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onDeletePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ }
+
+ onConfirmDelete = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ this.props.onDeletePress(this.getSelectedIds());
+ }
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ }
+
+ onLanguageChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onLanguageChange(selectedIds, parseInt(value));
+ }
+
+ onQualityChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onQualityChange(selectedIds, parseInt(value));
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isDeleting,
+ items,
+ languages,
+ qualities,
+ 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
+
+
+
+
+
+ );
+ }
+}
+
+MovieFileEditorModalContent.propTypes = {
+ seasonNumber: PropTypes.number,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onLanguageChange: PropTypes.func.isRequired,
+ onQualityChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default MovieFileEditorModalContent;
diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModalContentConnector.js b/frontend/src/MovieFile/Editor/MovieFileEditorModalContentConnector.js
new file mode 100644
index 000000000..f1ef55c7c
--- /dev/null
+++ b/frontend/src/MovieFile/Editor/MovieFileEditorModalContentConnector.js
@@ -0,0 +1,102 @@
+/* eslint max-params: 0 */
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import getQualities from 'Utilities/Quality/getQualities';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import { deleteMovieFiles, updateMovieFiles } from 'Store/Actions/movieFileActions';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import MovieFileEditorModalContent from './MovieFileEditorModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.movieFiles,
+ (state) => state.settings.qualityProfiles.schema,
+ createMovieSelector(),
+ (
+ movieFiles,
+ qualityProfileSchema,
+ movie
+ ) => {
+ const qualities = getQualities(qualityProfileSchema.items);
+
+ return {
+ items: movieFiles.items,
+ isDeleting: movieFiles.isDeleting,
+ isSaving: movieFiles.isSaving,
+ qualities
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchQualityProfileSchema(name, path) {
+ dispatch(fetchQualityProfileSchema());
+ },
+
+ dispatchUpdateMovieFiles(updateProps) {
+ dispatch(updateMovieFiles(updateProps));
+ },
+
+ onDeletePress(episodeFileIds) {
+ dispatch(deleteMovieFiles({ episodeFileIds }));
+ }
+ };
+}
+
+class MovieFileEditorModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+
+ //
+ // Render
+
+ //
+ // Listeners
+
+ onQualityChange = (episodeFileIds, qualityId) => {
+ const quality = {
+ quality: _.find(this.props.qualities, { id: qualityId }),
+ revision: {
+ version: 1,
+ real: 0
+ }
+ };
+
+ this.props.dispatchUpdateMovieFiles({ episodeFileIds, quality });
+ }
+
+ render() {
+ const {
+ dispatchFetchQualityProfileSchema,
+ dispatchUpdateMovieFiles,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+MovieFileEditorModalContentConnector.propTypes = {
+ movieId: PropTypes.number.isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
+ dispatchUpdateMovieFiles: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(MovieFileEditorModalContentConnector);
diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorRow.js b/frontend/src/MovieFile/Editor/MovieFileEditorRow.js
new file mode 100644
index 000000000..bab071699
--- /dev/null
+++ b/frontend/src/MovieFile/Editor/MovieFileEditorRow.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+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 MovieQuality from 'Movie/MovieQuality';
+
+function MovieFileEditorRow(props) {
+ const {
+ id,
+ relativePath,
+ airDateUtc,
+ language,
+ quality,
+ isSelected,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+
+ {relativePath}
+
+
+
+
+
+
+ {language.name}
+
+
+
+
+
+
+
+ );
+}
+
+MovieFileEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ 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 MovieFileEditorRow;
diff --git a/frontend/src/MovieFile/MediaInfo.js b/frontend/src/MovieFile/MediaInfo.js
new file mode 100644
index 000000000..75b264d58
--- /dev/null
+++ b/frontend/src/MovieFile/MediaInfo.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import * as mediaInfoTypes from './mediaInfoTypes';
+
+function MediaInfo(props) {
+ const {
+ type,
+ audioChannels,
+ audioCodec,
+ videoCodec
+ } = props;
+
+ if (type === mediaInfoTypes.AUDIO) {
+ return (
+
+ {
+ !!audioCodec &&
+ audioCodec
+ }
+
+ {
+ !!audioCodec && !!audioChannels &&
+ ' - '
+ }
+
+ {
+ !!audioChannels &&
+ audioChannels.toFixed(1)
+ }
+
+ );
+ }
+
+ if (type === mediaInfoTypes.VIDEO) {
+ return (
+
+ {videoCodec}
+
+ );
+ }
+
+ return null;
+}
+
+MediaInfo.propTypes = {
+ type: PropTypes.string.isRequired,
+ audioChannels: PropTypes.number,
+ audioCodec: PropTypes.string,
+ videoCodec: PropTypes.string
+};
+
+export default MediaInfo;
diff --git a/frontend/src/MovieFile/MediaInfoConnector.js b/frontend/src/MovieFile/MediaInfoConnector.js
new file mode 100644
index 000000000..55dec12f7
--- /dev/null
+++ b/frontend/src/MovieFile/MediaInfoConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
+import MediaInfo from './MediaInfo';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieFileSelector(),
+ (episodeFile) => {
+ if (episodeFile) {
+ return {
+ ...episodeFile.mediaInfo
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MediaInfo);
diff --git a/frontend/src/MovieFile/MovieFileLanguageConnector.js b/frontend/src/MovieFile/MovieFileLanguageConnector.js
new file mode 100644
index 000000000..466b458da
--- /dev/null
+++ b/frontend/src/MovieFile/MovieFileLanguageConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
+import EpisodeLanguage from 'Episode/EpisodeLanguage';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieFileSelector(),
+ (episodeFile) => {
+ return {
+ language: episodeFile ? episodeFile.language : undefined
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(EpisodeLanguage);
diff --git a/frontend/src/MovieFile/mediaInfoTypes.js b/frontend/src/MovieFile/mediaInfoTypes.js
new file mode 100644
index 000000000..5e5a78e64
--- /dev/null
+++ b/frontend/src/MovieFile/mediaInfoTypes.js
@@ -0,0 +1,2 @@
+export const AUDIO = 'audio';
+export const VIDEO = 'video';
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..319d8e72a
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.js
@@ -0,0 +1,201 @@
+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,
+ seasonNumber: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ renameEpisodes: PropTypes.bool,
+ episodeFormat: PropTypes.string,
+ onOrganizePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizePreviewModalContent;
diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js
new file mode 100644
index 000000000..a46c62ab9
--- /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 createMovieSelector from 'Store/Selectors/createMovieSelector';
+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,
+ createMovieSelector(),
+ (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.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/RootFolder/RootFolderRow.css b/frontend/src/RootFolder/RootFolderRow.css
new file mode 100644
index 000000000..d9c5ccb01
--- /dev/null
+++ b/frontend/src/RootFolder/RootFolderRow.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/RootFolder/RootFolderRow.js b/frontend/src/RootFolder/RootFolderRow.js
new file mode 100644
index 000000000..2a4038a54
--- /dev/null
+++ b/frontend/src/RootFolder/RootFolderRow.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './RootFolderRow.css';
+
+function RootFolderRow(props) {
+ const {
+ id,
+ path,
+ freeSpace,
+ unmappedFolders,
+ onDeletePress
+ } = props;
+
+ const unmappedFoldersCount = unmappedFolders.length || '-';
+
+ return (
+
+
+
+ {path}
+
+
+
+
+ {formatBytes(freeSpace) || '-'}
+
+
+
+ {unmappedFoldersCount}
+
+
+
+
+
+
+ );
+}
+
+RootFolderRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number.isRequired,
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired
+};
+
+RootFolderRow.defaultProps = {
+ freeSpace: 0,
+ unmappedFolders: []
+};
+
+export default RootFolderRow;
diff --git a/frontend/src/RootFolder/RootFolderRowConnector.js b/frontend/src/RootFolder/RootFolderRowConnector.js
new file mode 100644
index 000000000..ab0848e87
--- /dev/null
+++ b/frontend/src/RootFolder/RootFolderRowConnector.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { deleteRootFolder } from 'Store/Actions/rootFolderActions';
+import RootFolderRow from './RootFolderRow';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDeletePress() {
+ dispatch(deleteRootFolder({ id: props.id }));
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(RootFolderRow);
diff --git a/frontend/src/RootFolder/RootFolders.js b/frontend/src/RootFolder/RootFolders.js
new file mode 100644
index 000000000..57598dbb9
--- /dev/null
+++ b/frontend/src/RootFolder/RootFolders.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import RootFolderRowConnector from './RootFolderRowConnector';
+
+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
+ }
+];
+
+function RootFolders(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = props;
+
+ if (isFetching && !isPopulated) {
+ return (
+
+ );
+ }
+
+ if (!isFetching && !!error) {
+ return (
+ Unable to load root folders
+ );
+ }
+
+ return (
+
+
+ {
+ items.map((rootFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+}
+
+RootFolders.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default RootFolders;
diff --git a/frontend/src/RootFolder/RootFoldersConnector.js b/frontend/src/RootFolder/RootFoldersConnector.js
new file mode 100644
index 000000000..39f140bcc
--- /dev/null
+++ b/frontend/src/RootFolder/RootFoldersConnector.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 { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import RootFolders from './RootFolders';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ (rootFolders) => {
+ return rootFolders;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchRootFolders: fetchRootFolders
+};
+
+class RootFoldersConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchRootFolders();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RootFoldersConnector.propTypes = {
+ dispatchFetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector);
diff --git a/frontend/src/Settings/AdvancedSettingsButton.css b/frontend/src/Settings/AdvancedSettingsButton.css
new file mode 100644
index 000000000..4b7460ea2
--- /dev/null
+++ b/frontend/src/Settings/AdvancedSettingsButton.css
@@ -0,0 +1,31 @@
+.button {
+ composes: toolbarButton from 'Components/Page/Toolbar/PageToolbarButton.css';
+
+ position: relative;
+}
+
+.labelContainer {
+ composes: labelContainer from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.label {
+ composes: label from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.indicatorContainer {
+ position: absolute;
+ top: 10px;
+ right: 12px;
+}
+
+.indicatorBackground {
+ color: $themeDarkColor;
+}
+
+.enabled {
+ color: $successColor;
+}
+
+.disabled {
+ color: $dangerColor;
+}
diff --git a/frontend/src/Settings/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js
new file mode 100644
index 000000000..12d9902d5
--- /dev/null
+++ b/frontend/src/Settings/AdvancedSettingsButton.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './AdvancedSettingsButton.css';
+
+function AdvancedSettingsButton(props) {
+ const {
+ advancedSettings,
+ onAdvancedSettingsPress
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {advancedSettings ? 'Hide Advanced' : 'Show Advanced'}
+
+
+
+ );
+}
+
+AdvancedSettingsButton.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ onAdvancedSettingsPress: PropTypes.func.isRequired
+};
+
+export default AdvancedSettingsButton;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
new file mode 100644
index 000000000..c82604a88
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
+import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
+import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
+
+class DownloadClientSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isTestingAll,
+ dispatchTestAllDownloadClients
+ } = this.props;
+
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClientSettings.propTypes = {
+ isTestingAll: PropTypes.bool.isRequired,
+ dispatchTestAllDownloadClients: PropTypes.func.isRequired
+};
+
+export default DownloadClientSettings;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js
new file mode 100644
index 000000000..5e1a8a1ca
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllDownloadClients } from 'Store/Actions/settingsActions';
+import DownloadClientSettings from './DownloadClientSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients.isTestingAll,
+ (isTestingAll) => {
+ return {
+ isTestingAll
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchTestAllDownloadClients: testAllDownloadClients
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css
new file mode 100644
index 000000000..e1032ddef
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css
@@ -0,0 +1,44 @@
+.downloadClient {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js
new file mode 100644
index 000000000..3a2265d28
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem';
+import styles from './AddDownloadClientItem.css';
+
+class AddDownloadClientItem extends Component {
+
+ //
+ // Listeners
+
+ onDownloadClientSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onDownloadClientSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onDownloadClientSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddDownloadClientItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onDownloadClientSelect: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientItem;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js
new file mode 100644
index 000000000..0c21e7dbd
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector';
+
+function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddDownloadClientModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientModal;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css
new file mode 100644
index 000000000..b4d5c6787
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css
@@ -0,0 +1,5 @@
+.downloadClients {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js
new file mode 100644
index 000000000..a441f4327
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import AddDownloadClientItem from './AddDownloadClientItem';
+import styles from './AddDownloadClientModalContent.css';
+
+class AddDownloadClientModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetDownloadClients,
+ torrentDownloadClients,
+ onDownloadClientSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add DownloadClient
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new downloadClient, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+
+ Radarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
+ For more information on the individual downloadClients, clink on the info buttons.
+
+
+
+
+ {
+ usenetDownloadClients.map((downloadClient) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ torrentDownloadClients.map((downloadClient) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddDownloadClientModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
+ torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDownloadClientSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientModalContent;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js
new file mode 100644
index 000000000..99d5c4f19
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions';
+import AddDownloadClientModalContent from './AddDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients,
+ (downloadClients) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = downloadClients;
+
+ const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' });
+ const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetDownloadClients,
+ torrentDownloadClients
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClientSchema,
+ selectDownloadClientSchema
+};
+
+class AddDownloadClientModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClientSchema();
+ }
+
+ //
+ // Listeners
+
+ onDownloadClientSelect = ({ implementation }) => {
+ this.props.selectDownloadClientSchema({ implementation });
+ this.props.onModalClose({ downloadClientSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddDownloadClientModalContentConnector.propTypes = {
+ fetchDownloadClientSchema: PropTypes.func.isRequired,
+ selectDownloadClientSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js
new file mode 100644
index 000000000..f356f8140
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddDownloadClientPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddDownloadClientPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientPresetMenuItem;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css
new file mode 100644
index 000000000..cfeacec77
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css
@@ -0,0 +1,19 @@
+.downloadClient {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
new file mode 100644
index 000000000..6a86fef16
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
@@ -0,0 +1,113 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
+import styles from './DownloadClient.css';
+
+class DownloadClient extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditDownloadClientModalOpen: false,
+ isDeleteDownloadClientModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditDownloadClientPress = () => {
+ this.setState({ isEditDownloadClientModalOpen: true });
+ }
+
+ onEditDownloadClientModalClose = () => {
+ this.setState({ isEditDownloadClientModalOpen: false });
+ }
+
+ onDeleteDownloadClientPress = () => {
+ this.setState({
+ isEditDownloadClientModalOpen: false,
+ isDeleteDownloadClientModalOpen: true
+ });
+ }
+
+ onDeleteDownloadClientModalClose= () => {
+ this.setState({ isDeleteDownloadClientModalOpen: false });
+ }
+
+ onConfirmDeleteDownloadClient = () => {
+ this.props.onConfirmDeleteDownloadClient(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enable
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ enable ?
+
+ Enabled
+ :
+
+ Disabled
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClient.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enable: PropTypes.bool.isRequired,
+ onConfirmDeleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default DownloadClient;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css
new file mode 100644
index 000000000..ad53e6311
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css
@@ -0,0 +1,20 @@
+.downloadClients {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addDownloadClient {
+ composes: downloadClient from './DownloadClient.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
new file mode 100644
index 000000000..029845025
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import DownloadClient from './DownloadClient';
+import AddDownloadClientModal from './AddDownloadClientModal';
+import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
+import styles from './DownloadClients.css';
+
+class DownloadClients extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddDownloadClientModalOpen: false,
+ isEditDownloadClientModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddDownloadClientPress = () => {
+ this.setState({ isAddDownloadClientModalOpen: true });
+ }
+
+ onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => {
+ this.setState({
+ isAddDownloadClientModalOpen: false,
+ isEditDownloadClientModalOpen: downloadClientSelected
+ });
+ }
+
+ onEditDownloadClientModalClose = () => {
+ this.setState({ isEditDownloadClientModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteDownloadClient,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddDownloadClientModalOpen,
+ isEditDownloadClientModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClients.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default DownloadClients;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js
new file mode 100644
index 000000000..d318bc163
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDownloadClients, deleteDownloadClient } from 'Store/Actions/settingsActions';
+import DownloadClients from './DownloadClients';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients,
+ (downloadClients) => {
+ return {
+ ...downloadClients
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClients,
+ deleteDownloadClient
+};
+
+class DownloadClientsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClients();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteDownloadClient = (id) => {
+ this.props.deleteDownloadClient({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientsConnector.propTypes = {
+ fetchDownloadClients: PropTypes.func.isRequired,
+ deleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js
new file mode 100644
index 000000000..f6b07599c
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector';
+
+function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditDownloadClientModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditDownloadClientModal;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js
new file mode 100644
index 000000000..b5e5520fb
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelTestDownloadClient, cancelSaveDownloadClient } from 'Store/Actions/settingsActions';
+import EditDownloadClientModal from './EditDownloadClientModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.downloadClients';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestDownloadClient() {
+ dispatch(cancelTestDownloadClient({ section }));
+ },
+
+ dispatchCancelSaveDownloadClient() {
+ dispatch(cancelSaveDownloadClient({ section }));
+ }
+ };
+}
+
+class EditDownloadClientModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestDownloadClient();
+ this.props.dispatchCancelSaveDownloadClient();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestDownloadClient,
+ dispatchCancelSaveDownloadClient,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditDownloadClientModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestDownloadClient: PropTypes.func.isRequired,
+ dispatchCancelSaveDownloadClient: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css
new file mode 100644
index 000000000..c73406b57
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css
@@ -0,0 +1,11 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.message {
+ composes: alert from 'Components/Alert.css';
+
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js
new file mode 100644
index 000000000..60eff3eb8
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditDownloadClientModalContent.css';
+
+class EditDownloadClientModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteDownloadClientPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ enable,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Download Client - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new download client, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+EditDownloadClientModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isTesting: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteDownloadClientPress: PropTypes.func
+};
+
+export default EditDownloadClientModalContent;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
new file mode 100644
index 000000000..75f6f0bc3
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions';
+import EditDownloadClientModalContent from './EditDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('downloadClients'),
+ (advancedSettings, downloadClient) => {
+ return {
+ advancedSettings,
+ ...downloadClient
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDownloadClientValue,
+ setDownloadClientFieldValue,
+ saveDownloadClient,
+ testDownloadClient
+};
+
+class EditDownloadClientModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDownloadClientValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setDownloadClientFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveDownloadClient({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testDownloadClient({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDownloadClientModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDownloadClientValue: PropTypes.func.isRequired,
+ setDownloadClientFieldValue: PropTypes.func.isRequired,
+ saveDownloadClient: PropTypes.func.isRequired,
+ testDownloadClient: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
new file mode 100644
index 000000000..38a391891
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
@@ -0,0 +1,135 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function DownloadClientOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
Unable to load download client options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+
+
+
+
+
+
+
+
+ }
+
+ );
+}
+
+DownloadClientOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default DownloadClientOptions;
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
new file mode 100644
index 000000000..ef97de47f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchDownloadClientOptions, setDownloadClientOptionsValue, saveDownloadClientOptions } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import DownloadClientOptions from './DownloadClientOptions';
+
+const SECTION = 'downloadClientOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchDownloadClientOptions: fetchDownloadClientOptions,
+ dispatchSetDownloadClientOptionsValue: setDownloadClientOptionsValue,
+ dispatchSaveDownloadClientOptions: saveDownloadClientOptions,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class DownloadClientOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchDownloadClientOptions,
+ dispatchSaveDownloadClientOptions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchDownloadClientOptions();
+ onChildMounted(dispatchSaveDownloadClientOptions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetDownloadClientOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientOptionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchDownloadClientOptions: PropTypes.func.isRequired,
+ dispatchSetDownloadClientOptionsValue: PropTypes.func.isRequired,
+ dispatchSaveDownloadClientOptions: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientOptionsConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js
new file mode 100644
index 000000000..f66113619
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditRemotePathMappingModalContentConnector from './EditRemotePathMappingModalContentConnector';
+
+function EditRemotePathMappingModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditRemotePathMappingModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditRemotePathMappingModal;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js
new file mode 100644
index 000000000..94172429d
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditRemotePathMappingModal from './EditRemotePathMappingModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditRemotePathMappingModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.remotePathMappings' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRemotePathMappingModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css
new file mode 100644
index 000000000..0071acc4e
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css
@@ -0,0 +1,12 @@
+.body {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ flex: 1 1 430px;
+}
+
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js
new file mode 100644
index 000000000..59c1cb498
--- /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..3f25dbd0f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
+import styles from './RemotePathMapping.css';
+
+class RemotePathMapping extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditRemotePathMappingModalOpen: false,
+ isDeleteRemotePathMappingModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditRemotePathMappingPress = () => {
+ this.setState({ isEditRemotePathMappingModalOpen: true });
+ }
+
+ onEditRemotePathMappingModalClose = () => {
+ this.setState({ isEditRemotePathMappingModalOpen: false });
+ }
+
+ onDeleteRemotePathMappingPress = () => {
+ this.setState({
+ isEditRemotePathMappingModalOpen: false,
+ isDeleteRemotePathMappingModalOpen: true
+ });
+ }
+
+ onDeleteRemotePathMappingModalClose = () => {
+ this.setState({ isDeleteRemotePathMappingModalOpen: false });
+ }
+
+ onConfirmDeleteRemotePathMapping = () => {
+ this.props.onConfirmDeleteRemotePathMapping(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ host,
+ remotePath,
+ localPath
+ } = this.props;
+
+ return (
+
+
{host}
+
{remotePath}
+
{localPath}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RemotePathMapping.propTypes = {
+ id: PropTypes.number.isRequired,
+ host: PropTypes.string.isRequired,
+ remotePath: PropTypes.string.isRequired,
+ localPath: PropTypes.string.isRequired,
+ onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+RemotePathMapping.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default RemotePathMapping;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css
new file mode 100644
index 000000000..4ef9dcb0f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css
@@ -0,0 +1,23 @@
+.remotePathMappingsHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.host {
+ flex: 0 0 300px;
+}
+
+.path {
+ flex: 0 0 400px;
+}
+
+.addRemotePathMapping {
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 10px;
+}
+
+.addButton {
+ text-align: center;
+}
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
new file mode 100644
index 000000000..f633a3279
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import RemotePathMapping from './RemotePathMapping';
+import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
+import styles from './RemotePathMappings.css';
+
+class RemotePathMappings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddRemotePathMappingModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddRemotePathMappingPress = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteRemotePathMapping,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
Host
+
Remote Path
+
Local Path
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RemotePathMappings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default RemotePathMappings;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js
new file mode 100644
index 000000000..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/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js
new file mode 100644
index 000000000..10df6a9b2
--- /dev/null
+++ b/frontend/src/Settings/General/AnalyticSettings.js
@@ -0,0 +1,42 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function AnalyticSettings(props) {
+ const {
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ analyticsEnabled
+ } = settings;
+
+ return (
+
+
+ Send Anonymous Usage Data
+
+
+
+
+ );
+}
+
+AnalyticSettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default AnalyticSettings;
diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js
new file mode 100644
index 000000000..8b416fbc1
--- /dev/null
+++ b/frontend/src/Settings/General/BackupSettings.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function BackupSettings(props) {
+ const {
+ advancedSettings,
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ backupFolder,
+ backupInterval,
+ backupRetention
+ } = settings;
+
+ if (!advancedSettings) {
+ return null;
+ }
+
+ return (
+
+
+ Folder
+
+
+
+
+
+ Interval
+
+
+
+
+
+ Retention
+
+
+
+
+ );
+}
+
+BackupSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default BackupSettings;
diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js
new file mode 100644
index 000000000..772112582
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettings.js
@@ -0,0 +1,210 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import Form from 'Components/Form/Form';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import AnalyticSettings from './AnalyticSettings';
+import BackupSettings from './BackupSettings';
+import HostSettings from './HostSettings';
+import LoggingSettings from './LoggingSettings';
+import ProxySettings from './ProxySettings';
+import SecuritySettings from './SecuritySettings';
+import UpdateSettings from './UpdateSettings';
+
+class GeneralSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestartRequiredModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ settings,
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (isSaving || saveError || !prevProps.isSaving) {
+ return;
+ }
+
+ const prevSettings = prevProps.settings;
+
+ const keys = [
+ 'bindAddress',
+ 'port',
+ 'urlBase',
+ 'enableSsl',
+ 'sslPort',
+ 'sslCertHash',
+ 'authenticationMethod',
+ 'username',
+ 'password',
+ 'apiKey'
+ ];
+
+ const pendingRestart = _.some(keys, (key) => {
+ const setting = settings[key];
+ const prevSetting = prevSettings[key];
+
+ if (!setting || !prevSetting) {
+ return false;
+ }
+
+ const previousValue = prevSetting.previousValue;
+ const value = setting.value;
+
+ return previousValue != null && previousValue !== value;
+ });
+
+ this.setState({ isRestartRequiredModalOpen: pendingRestart });
+ }
+
+ //
+ // Listeners
+
+ onConfirmRestart = () => {
+ this.setState({ isRestartRequiredModalOpen: false });
+ this.props.onConfirmRestart();
+ }
+
+ onCloseRestartRequiredModalOpen = () => {
+ this.setState({ isRestartRequiredModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ isPopulated,
+ error,
+ settings,
+ hasSettings,
+ isResettingApiKey,
+ isMono,
+ isWindows,
+ mode,
+ onInputChange,
+ onConfirmResetApiKey,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load General settings
+ }
+
+ {
+ hasSettings && isPopulated && !error &&
+
+ }
+
+
+
+
+ );
+ }
+
+}
+
+GeneralSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ isResettingApiKey: PropTypes.bool.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ isWindows: PropTypes.bool.isRequired,
+ mode: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onConfirmResetApiKey: PropTypes.func.isRequired,
+ onConfirmRestart: PropTypes.func.isRequired
+};
+
+export default GeneralSettings;
diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js
new file mode 100644
index 000000000..804fdfde7
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettingsConnector.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { setGeneralSettingsValue, saveGeneralSettings, fetchGeneralSettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { restart } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import GeneralSettings from './GeneralSettings';
+
+const SECTION = 'general';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ createCommandExecutingSelector(commandNames.RESET_API_KEY),
+ createSystemStatusSelector(),
+ (advancedSettings, sectionSettings, isResettingApiKey, systemStatus) => {
+ return {
+ advancedSettings,
+ isResettingApiKey,
+ isMono: systemStatus.isMono,
+ isWindows: systemStatus.isWindows,
+ mode: systemStatus.mode,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setGeneralSettingsValue,
+ saveGeneralSettings,
+ fetchGeneralSettings,
+ executeCommand,
+ restart,
+ clearPendingChanges
+};
+
+class GeneralSettingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchGeneralSettings();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.isResettingApiKey && prevProps.isResettingApiKey) {
+ this.props.fetchGeneralSettings();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setGeneralSettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveGeneralSettings();
+ }
+
+ onConfirmResetApiKey = () => {
+ this.props.executeCommand({ name: commandNames.RESET_API_KEY });
+ }
+
+ onConfirmRestart = () => {
+ this.props.restart();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+GeneralSettingsConnector.propTypes = {
+ isResettingApiKey: PropTypes.bool.isRequired,
+ setGeneralSettingsValue: PropTypes.func.isRequired,
+ saveGeneralSettings: PropTypes.func.isRequired,
+ fetchGeneralSettings: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ restart: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(GeneralSettingsConnector);
diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js
new file mode 100644
index 000000000..42c97e0dd
--- /dev/null
+++ b/frontend/src/Settings/General/HostSettings.js
@@ -0,0 +1,154 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function HostSettings(props) {
+ const {
+ advancedSettings,
+ settings,
+ isWindows,
+ mode,
+ onInputChange
+ } = props;
+
+ const {
+ bindAddress,
+ port,
+ urlBase,
+ enableSsl,
+ sslPort,
+ sslCertHash,
+ launchBrowser
+ } = settings;
+
+ return (
+
+
+ Bind Address
+
+
+
+
+
+ Port Number
+
+
+
+
+
+ URL Base
+
+
+
+
+
+ Enable SSL
+
+
+
+
+ {
+ enableSsl.value &&
+
+ SSL Port
+
+
+
+ }
+
+ {
+ isWindows && enableSsl.value &&
+
+ SSL Cert Hash
+
+
+
+ }
+
+ {
+ mode !== 'service' &&
+
+ Open browser on start
+
+
+
+ }
+
+
+ );
+}
+
+HostSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+ isWindows: PropTypes.bool.isRequired,
+ mode: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default HostSettings;
diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js
new file mode 100644
index 000000000..e7853328e
--- /dev/null
+++ b/frontend/src/Settings/General/LoggingSettings.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function LoggingSettings(props) {
+ const {
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ logLevel
+ } = settings;
+
+ const logLevelOptions = [
+ { key: 'info', value: 'Info' },
+ { key: 'debug', value: 'Debug' },
+ { key: 'trace', value: 'Trace' }
+ ];
+
+ return (
+
+
+ Log Level
+
+
+
+
+ );
+}
+
+LoggingSettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default LoggingSettings;
diff --git a/frontend/src/Settings/General/ProxySettings.js b/frontend/src/Settings/General/ProxySettings.js
new file mode 100644
index 000000000..238cf3a30
--- /dev/null
+++ b/frontend/src/Settings/General/ProxySettings.js
@@ -0,0 +1,142 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function ProxySettings(props) {
+ const {
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ proxyEnabled,
+ proxyType,
+ proxyHostname,
+ proxyPort,
+ proxyUsername,
+ proxyPassword,
+ proxyBypassFilter,
+ proxyBypassLocalAddresses
+ } = settings;
+
+ const proxyTypeOptions = [
+ { key: 'http', value: 'HTTP(S)' },
+ { key: 'socks4', value: 'Socks4' },
+ { key: 'socks5', value: 'Socks5 (Support TOR)' }
+ ];
+
+ return (
+
+
+ Use Proxy
+
+
+
+
+ {
+ proxyEnabled.value &&
+
+
+ Proxy Type
+
+
+
+
+
+ Hostname
+
+
+
+
+
+ Port
+
+
+
+
+
+ Username
+
+
+
+
+
+ Password
+
+
+
+
+
+ Ignored Addresses
+
+
+
+
+
+ Bypass Proxy for Local Addresses
+
+
+
+
+ }
+
+ );
+}
+
+ProxySettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default ProxySettings;
diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js
new file mode 100644
index 000000000..0bef91050
--- /dev/null
+++ b/frontend/src/Settings/General/SecuritySettings.js
@@ -0,0 +1,170 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, inputTypes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import ClipboardButton from 'Components/Link/ClipboardButton';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormInputButton from 'Components/Form/FormInputButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+
+class SecuritySettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isConfirmApiKeyResetModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onApikeyFocus = (event) => {
+ event.target.select();
+ }
+
+ onResetApiKeyPress = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: true });
+ }
+
+ onConfirmResetApiKey = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ this.props.onConfirmResetApiKey();
+ }
+
+ onCloseResetApiKeyModal = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ settings,
+ isResettingApiKey,
+ onInputChange
+ } = this.props;
+
+ const {
+ authenticationMethod,
+ username,
+ password,
+ apiKey
+ } = settings;
+
+ const authenticationMethodOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'basic', value: 'Basic (Browser Popup)' },
+ { key: 'forms', value: 'Forms (Login Page)' }
+ ];
+
+ const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
+
+ return (
+
+
+ Authentication
+
+
+
+
+ {
+ authenticationEnabled &&
+
+ Username
+
+
+
+ }
+
+ {
+ authenticationEnabled &&
+
+ Password
+
+
+
+ }
+
+
+ API Key
+
+ ,
+
+
+
+
+ ]}
+ onChange={onInputChange}
+ onFocus={this.onApikeyFocus}
+ {...apiKey}
+ />
+
+
+
+
+ );
+ }
+}
+
+SecuritySettings.propTypes = {
+ settings: PropTypes.object.isRequired,
+ isResettingApiKey: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onConfirmResetApiKey: PropTypes.func.isRequired
+};
+
+export default SecuritySettings;
diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js
new file mode 100644
index 000000000..4a7b02d85
--- /dev/null
+++ b/frontend/src/Settings/General/UpdateSettings.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function UpdateSettings(props) {
+ const {
+ advancedSettings,
+ settings,
+ isMono,
+ onInputChange
+ } = props;
+
+ const {
+ branch,
+ updateAutomatically,
+ updateMechanism,
+ updateScriptPath
+ } = settings;
+
+ if (!advancedSettings) {
+ return null;
+ }
+
+ const updateOptions = [
+ { key: 'builtIn', value: 'Built-In' },
+ { key: 'script', value: 'Script' }
+ ];
+
+ return (
+
+
+ Branch
+
+
+
+
+ {
+ isMono &&
+
+
+ Automatic
+
+
+
+
+
+ Mechanism
+
+
+
+
+ {
+ updateMechanism.value === 'script' &&
+
+ Script Path
+
+
+
+ }
+
+ }
+
+ );
+}
+
+UpdateSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default UpdateSettings;
diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js
new file mode 100644
index 000000000..161224999
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettings.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import 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._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ // Render
+ //
+
+ render() {
+ const {
+ isTestingAll,
+ dispatchTestAllIndexers
+ } = this.props;
+
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+IndexerSettings.propTypes = {
+ isTestingAll: PropTypes.bool.isRequired,
+ dispatchTestAllIndexers: PropTypes.func.isRequired
+};
+
+export default IndexerSettings;
diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js
new file mode 100644
index 000000000..1eaf098d7
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllIndexers } from 'Store/Actions/settingsActions';
+import IndexerSettings from './IndexerSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers.isTestingAll,
+ (isTestingAll) => {
+ return {
+ isTestingAll
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchTestAllIndexers: testAllIndexers
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSettings);
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css
new file mode 100644
index 000000000..d228b842b
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css
@@ -0,0 +1,44 @@
+.indexer {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js
new file mode 100644
index 000000000..21db4ecf1
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem';
+import styles from './AddIndexerItem.css';
+
+class AddIndexerItem extends Component {
+
+ //
+ // Listeners
+
+ onIndexerSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onIndexerSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onIndexerSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddIndexerItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onIndexerSelect: PropTypes.func.isRequired
+};
+
+export default AddIndexerItem;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js
new file mode 100644
index 000000000..d05e8eb9a
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddIndexerModalContentConnector from './AddIndexerModalContentConnector';
+
+function AddIndexerModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddIndexerModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddIndexerModal;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css
new file mode 100644
index 000000000..946305dff
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css
@@ -0,0 +1,5 @@
+.indexers {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js
new file mode 100644
index 000000000..59f74e4a2
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import AddIndexerItem from './AddIndexerItem';
+import styles from './AddIndexerModalContent.css';
+
+class AddIndexerModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetIndexers,
+ torrentIndexers,
+ onIndexerSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Indexer
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+
+ Radarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
+ For more information on the individual indexers, clink on the info buttons.
+
+
+
+
+ {
+ usenetIndexers.map((indexer) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ torrentIndexers.map((indexer) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddIndexerModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
+ torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onIndexerSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddIndexerModalContent;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js
new file mode 100644
index 000000000..d79f028da
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions';
+import AddIndexerModalContent from './AddIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (indexers) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = indexers;
+
+ const usenetIndexers = _.filter(schema, { protocol: 'usenet' });
+ const torrentIndexers = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ usenetIndexers,
+ torrentIndexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexerSchema,
+ selectIndexerSchema
+};
+
+class AddIndexerModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexerSchema();
+ }
+
+ //
+ // Listeners
+
+ onIndexerSelect = ({ implementation, name }) => {
+ this.props.selectIndexerSchema({ implementation, presetName: name });
+ this.props.onModalClose({ indexerSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddIndexerModalContentConnector.propTypes = {
+ fetchIndexerSchema: PropTypes.func.isRequired,
+ selectIndexerSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js
new file mode 100644
index 000000000..ddea8b043
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddIndexerPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddIndexerPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddIndexerPresetMenuItem;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js
new file mode 100644
index 000000000..d7401b95f
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditIndexerModalContentConnector from './EditIndexerModalContentConnector';
+
+function EditIndexerModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditIndexerModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditIndexerModal;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js
new file mode 100644
index 000000000..ec0b7586e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelTestIndexer, cancelSaveIndexer } from 'Store/Actions/settingsActions';
+import EditIndexerModal from './EditIndexerModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.indexers';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestIndexer() {
+ dispatch(cancelTestIndexer({ section }));
+ },
+
+ dispatchCancelSaveIndexer() {
+ dispatch(cancelSaveIndexer({ section }));
+ }
+ };
+}
+
+class EditIndexerModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestIndexer();
+ this.props.dispatchCancelSaveIndexer();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestIndexer,
+ dispatchCancelSaveIndexer,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditIndexerModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestIndexer: PropTypes.func.isRequired,
+ dispatchCancelSaveIndexer: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditIndexerModalConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js
new file mode 100644
index 000000000..de81fa3bd
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js
@@ -0,0 +1,179 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditIndexerModalContent.css';
+
+function EditIndexerModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteIndexerPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ enableRss,
+ enableSearch,
+ supportsRss,
+ supportsSearch,
+ fields
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Indexer - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditIndexerModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteIndexerPress: PropTypes.func
+};
+
+export default EditIndexerModalContent;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
new file mode 100644
index 000000000..f993d2796
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions';
+import EditIndexerModalContent from './EditIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('indexers'),
+ (advancedSettings, indexer) => {
+ return {
+ advancedSettings,
+ ...indexer
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setIndexerValue,
+ setIndexerFieldValue,
+ saveIndexer,
+ testIndexer
+};
+
+class EditIndexerModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setIndexerValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setIndexerFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveIndexer({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testIndexer({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditIndexerModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setIndexerValue: PropTypes.func.isRequired,
+ setIndexerFieldValue: PropTypes.func.isRequired,
+ saveIndexer: PropTypes.func.isRequired,
+ testIndexer: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.css b/frontend/src/Settings/Indexers/Indexers/Indexer.css
new file mode 100644
index 000000000..d8e1a731e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.css
@@ -0,0 +1,19 @@
+.indexer {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js
new file mode 100644
index 000000000..7974e11d0
--- /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';
+
+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}
+
+
+
+
+ {
+ supportsRss && enableRss &&
+
+ RSS
+
+ }
+
+ {
+ supportsSearch && enableSearch &&
+
+ Search
+
+ }
+
+ {
+ !enableRss && !enableSearch &&
+
+ Disabled
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+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..f5fea9aac
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Indexer from './Indexer';
+import AddIndexerModal from './AddIndexerModal';
+import EditIndexerModalConnector from './EditIndexerModalConnector';
+import styles from './Indexers.css';
+
+class Indexers extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddIndexerModalOpen: false,
+ isEditIndexerModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddIndexerPress = () => {
+ this.setState({ isAddIndexerModalOpen: true });
+ }
+
+ onAddIndexerModalClose = ({ indexerSelected = false } = {}) => {
+ this.setState({
+ isAddIndexerModalOpen: false,
+ isEditIndexerModalOpen: indexerSelected
+ });
+ }
+
+ onEditIndexerModalClose = () => {
+ this.setState({ isEditIndexerModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteIndexer,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddIndexerModalOpen,
+ isEditIndexerModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Indexers.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteIndexer: PropTypes.func.isRequired
+};
+
+export default Indexers;
diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js
new file mode 100644
index 000000000..415dae32b
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexers, deleteIndexer } from 'Store/Actions/settingsActions';
+import Indexers from './Indexers';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (indexers) => {
+ return {
+ ...indexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexers,
+ deleteIndexer
+};
+
+class IndexersConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexers();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteIndexer = (id) => {
+ this.props.deleteIndexer({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexersConnector.propTypes = {
+ fetchIndexers: PropTypes.func.isRequired,
+ deleteIndexer: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexersConnector);
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
new file mode 100644
index 000000000..b7ac3bbf6
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
@@ -0,0 +1,112 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function IndexerOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load indexer options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+}
+
+IndexerOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default IndexerOptions;
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
new file mode 100644
index 000000000..28d07c77e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchIndexerOptions, setIndexerOptionsValue, saveIndexerOptions } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import IndexerOptions from './IndexerOptions';
+
+const SECTION = 'indexerOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchIndexerOptions: fetchIndexerOptions,
+ dispatchSetIndexerOptionsValue: setIndexerOptionsValue,
+ dispatchSaveIndexerOptions: saveIndexerOptions,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class IndexerOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchIndexerOptions,
+ dispatchSaveIndexerOptions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchIndexerOptions();
+ onChildMounted(dispatchSaveIndexerOptions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetIndexerOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexerOptionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchIndexerOptions: PropTypes.func.isRequired,
+ dispatchSetIndexerOptionsValue: PropTypes.func.isRequired,
+ dispatchSaveIndexerOptions: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerOptionsConnector);
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js
new file mode 100644
index 000000000..31c36ea6a
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import 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..0089d153e
--- /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: 'settings.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..07b7feece
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restriction.js
@@ -0,0 +1,148 @@
+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..3c9493b39
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restrictions.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import 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..7e892f460
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -0,0 +1,393 @@
+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 RootFoldersConnector from 'RootFolder/RootFoldersConnector';
+import NamingConnector from './Naming/NamingConnector';
+
+const rescanAfterRefreshOptions = [
+ { key: 'always', value: 'Always' },
+ { key: 'afterManual', value: 'After Manual Refresh' },
+ { key: 'never', value: 'Never' }
+];
+
+const fileDateOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'cinemas', value: 'In Cinemas Date' },
+ { key: 'release', value: 'Physical Release Date' }
+];
+
+class MediaManagement extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ isMono,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+ {
+ isFetching &&
+
+
+
+ }
+
+ {
+ !isFetching && error &&
+
+ Unable to load Media Management settings
+
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+
+}
+
+MediaManagement.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default MediaManagement;
diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
new file mode 100644
index 000000000..6412b6cd2
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
@@ -0,0 +1,86 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { fetchMediaManagementSettings, setMediaManagementSettingsValue, saveMediaManagementSettings, saveNamingSettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import MediaManagement from './MediaManagement';
+
+const SECTION = 'mediaManagement';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.naming,
+ createSettingsSectionSelector(SECTION),
+ createSystemStatusSelector(),
+ (advancedSettings, namingSettings, sectionSettings, systemStatus) => {
+ return {
+ advancedSettings,
+ ...sectionSettings,
+ hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges,
+ isMono: systemStatus.isMono
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMediaManagementSettings,
+ setMediaManagementSettingsValue,
+ saveMediaManagementSettings,
+ saveNamingSettings,
+ clearPendingChanges
+};
+
+class MediaManagementConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMediaManagementSettings();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: `settings.${SECTION}` });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMediaManagementSettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMediaManagementSettings();
+ this.props.saveNamingSettings();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MediaManagementConnector.propTypes = {
+ fetchMediaManagementSettings: PropTypes.func.isRequired,
+ setMediaManagementSettingsValue: PropTypes.func.isRequired,
+ saveMediaManagementSettings: PropTypes.func.isRequired,
+ saveNamingSettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css
new file mode 100644
index 000000000..da27e292e
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css
@@ -0,0 +1,5 @@
+.namingInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js
new file mode 100644
index 000000000..57886934a
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js
@@ -0,0 +1,202 @@
+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: 'standardMovieFormat',
+ additional: true
+ }
+ });
+ }
+
+ onMovieFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'movieFolderFormat'
+ }
+ });
+ }
+
+ 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 standardMovieFormatHelpTexts = [];
+ const standardMovieFormatErrors = [];
+ const movieFolderFormatHelpTexts = [];
+ const movieFolderFormatErrors = [];
+
+ if (examplesPopulated) {
+ if (examples.movieExample) {
+ standardMovieFormatHelpTexts.push(`Movie: ${examples.movieExample}`);
+ } else {
+ standardMovieFormatErrors.push({ message: 'Movie: Invalid Format' });
+ }
+
+ if (examples.movieFolderExample) {
+ movieFolderFormatHelpTexts.push(`Example: ${examples.movieFolderExample}`);
+ } else {
+ movieFolderFormatErrors.push({ message: 'Invalid Format' });
+ }
+ }
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load Naming settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+ }
+
+}
+
+Naming.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ examples: PropTypes.object.isRequired,
+ examplesPopulated: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default Naming;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
new file mode 100644
index 000000000..d8210317e
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
@@ -0,0 +1,97 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchNamingSettings, setNamingSettingsValue, fetchNamingExamples } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import Naming from './Naming';
+
+const SECTION = 'naming';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.namingExamples,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, examples, sectionSettings) => {
+ return {
+ advancedSettings,
+ examples: examples.item,
+ examplesPopulated: !_.isEmpty(examples.item),
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNamingSettings,
+ setNamingSettingsValue,
+ fetchNamingExamples,
+ clearPendingChanges
+};
+
+class NamingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._namingExampleTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchNamingSettings();
+ this.props.fetchNamingExamples();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Control
+
+ _fetchNamingExamples = () => {
+ this.props.fetchNamingExamples();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNamingSettingsValue({ name, value });
+
+ if (this._namingExampleTimeout) {
+ clearTimeout(this._namingExampleTimeout);
+ }
+
+ this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NamingConnector.propTypes = {
+ fetchNamingSettings: PropTypes.func.isRequired,
+ setNamingSettingsValue: PropTypes.func.isRequired,
+ fetchNamingExamples: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
new file mode 100644
index 000000000..de6a54e7f
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
@@ -0,0 +1,18 @@
+.groups {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+
+.namingSelectContainer {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.namingSelect {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ margin-left: 10px;
+ width: 200px;
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
new file mode 100644
index 000000000..7f65f9e76
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
@@ -0,0 +1,549 @@
+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._selectionStart = null;
+ this._selectionEnd = null;
+
+ this.state = {
+ separator: ' ',
+ case: 'title'
+ };
+ }
+
+ //
+ // Listeners
+
+ onTokenSeparatorChange = (event) => {
+ this.setState({ separator: event.value });
+ }
+
+ onTokenCaseChange = (event) => {
+ this.setState({ case: event.value });
+ }
+
+ onInputSelectionChange = (selectionStart, selectionEnd) => {
+ this._selectionStart = selectionStart;
+ this._selectionEnd = selectionEnd;
+ }
+
+ onOptionPress = ({ isFullFilename, tokenValue }) => {
+ const {
+ name,
+ value,
+ onInputChange
+ } = this.props;
+
+ const selectionStart = this._selectionStart;
+ const selectionEnd = this._selectionEnd;
+
+ if (isFullFilename) {
+ onInputChange({ name, value: tokenValue });
+ } else if (selectionStart == null) {
+ onInputChange({
+ name,
+ value: `${value}${tokenValue}`
+ });
+ } else {
+ const start = value.substring(0, selectionStart);
+ const end = value.substring(selectionEnd);
+ const newValue = `${start}${tokenValue}${end}`;
+
+ onInputChange({ name, value: newValue });
+ this._selectionStart = newValue.length - 1;
+ this._selectionEnd = newValue.length - 1;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ value,
+ isOpen,
+ advancedSettings,
+ season,
+ episode,
+ daily,
+ anime,
+ additional,
+ onInputChange,
+ onModalClose
+ } = this.props;
+
+ const {
+ separator: tokenSeparator,
+ case: tokenCase
+ } = this.state;
+
+ const separatorOptions = [
+ { key: ' ', value: 'Space ( )' },
+ { key: '.', value: 'Period (.)' },
+ { key: '_', value: 'Underscore (_)' },
+ { key: '-', value: 'Dash (-)' }
+ ];
+
+ const caseOptions = [
+ { key: 'title', value: 'Default Case' },
+ { key: 'lower', value: 'Lower Case' },
+ { key: 'upper', value: 'Upper Case' }
+ ];
+
+ const fileNameTokens = [
+ {
+ token: '{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}',
+ example: 'Series Title (2010) - S01E01 - Episode Title HDTV-720p Proper'
+ },
+ {
+ token: '{Series Title} - {season:0}x{episode:00} - {Episode Title} {Quality Full}',
+ example: 'Series Title (2010) - 1x01 - Episode Title HDTV-720p Proper'
+ },
+ {
+ token: '{Series.Title}.S{season:00}E{episode:00}.{EpisodeClean.Title}.{Quality.Full}',
+ example: 'Series.Title.(2010).S01E01.Episode.Title.HDTV-720p'
+ }
+ ];
+
+ const seriesTokens = [
+ { token: '{Series Title}', example: 'Series Title!' },
+ { token: '{Series CleanTitle}', example: 'Series Title' },
+ { token: '{Series CleanTitleYear}', example: 'Series Title 2010' },
+ { token: '{Series TitleThe}', example: 'Series Title, The' },
+ { token: '{Series TitleTheYear}', example: 'Series Title, The (2010)' },
+ { token: '{Series TitleYear}', example: 'Series Title (2010)' }
+ ];
+
+ const seriesIdTokens = [
+ { token: '{ImdbId}', example: 'tt12345' },
+ { token: '{TvdbId}', example: '12345' },
+ { token: '{TvMazeId}', example: '54321' }
+ ];
+
+ const seasonTokens = [
+ { token: '{season:0}', example: '1' },
+ { token: '{season:00}', example: '01' }
+ ];
+
+ const episodeTokens = [
+ { token: '{episode:0}', example: '1' },
+ { token: '{episode:00}', example: '01' }
+ ];
+
+ const airDateTokens = [
+ { token: '{Air-Date}', example: '2016-03-20' },
+ { token: '{Air Date}', example: '2016 03 20' }
+ ];
+
+ const absoluteTokens = [
+ { token: '{absolute:0}', example: '1' },
+ { token: '{absolute:00}', example: '01' },
+ { token: '{absolute:000}', example: '001' }
+ ];
+
+ const episodeTitleTokens = [
+ { token: '{Episode Title}', example: 'Episode Title' },
+ { token: '{Episode CleanTitle}', example: 'Episode Title' }
+ ];
+
+ const qualityTokens = [
+ { token: '{Quality Full}', example: 'HDTV 720p Proper' },
+ { token: '{Quality Title}', example: 'HDTV 720p' }
+ ];
+
+ const mediaInfoTokens = [
+ { token: '{MediaInfo Simple}', example: 'x264 DTS' },
+ { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' },
+ { token: '{MediaInfo VideoCodec}', example: 'x264' },
+ { token: '{MediaInfo AudioFormat}', example: 'DTS' },
+ { token: '{MediaInfo AudioChannels}', example: '5.1' }
+ ];
+
+ const releaseGroupTokens = [
+ { 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 (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ seriesIdTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ {
+ season &&
+
+
+ {
+ seasonTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+ {
+ episode &&
+
+
+
+ {
+ episodeTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ {
+ daily &&
+
+
+ {
+ airDateTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+ {
+ anime &&
+
+
+ {
+ absoluteTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+ }
+
+ {
+ additional &&
+
+
+
+ {
+ episodeTitleTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ qualityTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ mediaInfoTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ 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..269266a5f
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { sizes } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import styles from './NamingOption.css';
+
+class NamingOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ token,
+ tokenSeparator,
+ tokenCase,
+ isFullFilename,
+ onPress
+ } = this.props;
+
+ let tokenValue = token;
+
+ tokenValue = tokenValue.replace(/ /g, tokenSeparator);
+
+ if (tokenCase === 'lower') {
+ tokenValue = token.toLowerCase();
+ } else if (tokenCase === 'upper') {
+ tokenValue = token.toUpperCase();
+ }
+
+ onPress({ isFullFilename, tokenValue });
+ }
+
+ //
+ // Render
+ render() {
+ const {
+ token,
+ tokenSeparator,
+ example,
+ tokenCase,
+ isFullFilename,
+ size
+ } = this.props;
+
+ return (
+
+
+ {token.replace(/ /g, tokenSeparator)}
+
+
+
+ {example.replace(/ /g, tokenSeparator)}
+
+
+ );
+ }
+}
+
+NamingOption.propTypes = {
+ token: PropTypes.string.isRequired,
+ example: PropTypes.string.isRequired,
+ tokenSeparator: PropTypes.string.isRequired,
+ tokenCase: PropTypes.string.isRequired,
+ isFullFilename: PropTypes.bool.isRequired,
+ size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
+ onPress: PropTypes.func.isRequired
+};
+
+NamingOption.defaultProps = {
+ size: sizes.SMALL,
+ isFullFilename: false
+};
+
+export default NamingOption;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
new file mode 100644
index 000000000..24c0237cd
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditMetadataModalContentConnector from './EditMetadataModalContentConnector';
+
+function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditMetadataModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditMetadataModal;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
new file mode 100644
index 000000000..1a38beb4a
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditMetadataModal from './EditMetadataModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.metadata';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ }
+ };
+}
+
+class EditMetadataModalConnector extends Component {
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges({ section: 'metadata' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js
new file mode 100644
index 000000000..96bbb4b83
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+
+function EditMetadataModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ ...otherProps
+ } = props;
+
+ const {
+ name,
+ enable,
+ fields
+ } = item;
+
+ return (
+
+
+ Edit {name.value} Metadata
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditMetadataModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteMetadataPress: PropTypes.func
+};
+
+export default EditMetadataModalContent;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
new file mode 100644
index 000000000..2cd7636a0
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setMetadataValue, setMetadataFieldValue, saveMetadata } from 'Store/Actions/settingsActions';
+import EditMetadataModalContent from './EditMetadataModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.metadata,
+ (id, metadata) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = metadata;
+
+ const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError);
+
+ return {
+ id,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setMetadataValue,
+ setMetadataFieldValue,
+ saveMetadata
+};
+
+class EditMetadataModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMetadataValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setMetadataFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMetadata({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setMetadataValue: PropTypes.func.isRequired,
+ setMetadataFieldValue: PropTypes.func.isRequired,
+ saveMetadata: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css
new file mode 100644
index 000000000..31507ee23
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css
@@ -0,0 +1,15 @@
+.metadata {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.section {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js
new file mode 100644
index 000000000..eba01463c
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js
@@ -0,0 +1,149 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import EditMetadataModalConnector from './EditMetadataModalConnector';
+import styles from './Metadata.css';
+
+class Metadata extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMetadataModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMetadataPress = () => {
+ this.setState({ isEditMetadataModalOpen: true });
+ }
+
+ onEditMetadataModalClose = () => {
+ this.setState({ isEditMetadataModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enable,
+ fields
+ } = this.props;
+
+ const metadataFields = [];
+ const imageFields = [];
+
+ fields.forEach((field) => {
+ if (field.section === 'metadata') {
+ metadataFields.push(field);
+ } else {
+ imageFields.push(field);
+ }
+ });
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ enable ?
+
+ Enabled
+ :
+
+ Disabled
+
+ }
+
+
+ {
+ enable && !!metadataFields.length &&
+
+
+ Metadata
+
+
+ {
+ metadataFields.map((field) => {
+ if (!field.value) {
+ return null;
+ }
+
+ return (
+
+ {field.label}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ enable && !!imageFields.length &&
+
+
+ Images
+
+
+ {
+ imageFields.map((field) => {
+ if (!field.value) {
+ return null;
+ }
+
+ return (
+
+ {field.label}
+
+ );
+ })
+ }
+
+ }
+
+
+
+ );
+ }
+}
+
+Metadata.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enable: PropTypes.bool.isRequired,
+ fields: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadata;
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
new file mode 100644
index 000000000..fb1bd6080
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
@@ -0,0 +1,4 @@
+.metadatas {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
new file mode 100644
index 000000000..8659c5799
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Metadata from './Metadata';
+import styles from './Metadatas.css';
+
+function Metadatas(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+}
+
+Metadatas.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadatas;
diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
new file mode 100644
index 000000000..fb7153950
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchMetadata } from 'Store/Actions/settingsActions';
+import Metadatas from './Metadatas';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.metadata,
+ (metadata) => {
+ return {
+ ...metadata
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMetadata
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMetadata();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ fetchMetadata: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js
new file mode 100644
index 000000000..001936ab7
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataSettings.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import MetadatasConnector from './Metadata/MetadatasConnector';
+
+function MetadataSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default MetadataSettings;
diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.css b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.css
new file mode 100644
index 000000000..0dd9e22db
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.css
@@ -0,0 +1,44 @@
+.netImport {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.js
new file mode 100644
index 000000000..ea05a136b
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.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 AddNetImportPresetMenuItem from './AddNetImportPresetMenuItem';
+import styles from './AddNetImportItem.css';
+
+class AddNetImportItem extends Component {
+
+ //
+ // Listeners
+
+ onNetImportSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onNetImportSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onNetImportSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddNetImportItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onNetImportSelect: PropTypes.func.isRequired
+};
+
+export default AddNetImportItem;
diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModal.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportModal.js
new file mode 100644
index 000000000..4f1921e5b
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNetImportModalContentConnector from './AddNetImportModalContentConnector';
+
+function AddNetImportModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddNetImportModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNetImportModal;
diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.css b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.css
new file mode 100644
index 000000000..6f57c542e
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.css
@@ -0,0 +1,5 @@
+.netImports {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.js
new file mode 100644
index 000000000..7824a554e
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.js
@@ -0,0 +1,96 @@
+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 AddNetImportItem from './AddNetImportItem';
+import styles from './AddNetImportModalContent.css';
+
+class AddNetImportModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ netImports,
+ onNetImportSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add List
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new list, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+
+ Radarr supports any RSS movie lists as well as the one stated below.
+ For more information on the individual netImports, clink on the info buttons.
+
+
+
+
+ {
+ netImports.map((netImport) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddNetImportModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ netImports: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNetImportSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNetImportModalContent;
diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContentConnector.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContentConnector.js
new file mode 100644
index 000000000..39e43b366
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContentConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchNetImportSchema, selectNetImportSchema } from 'Store/Actions/settingsActions';
+import AddNetImportModalContent from './AddNetImportModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.netImports,
+ (netImports) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = netImports;
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ netImports: schema
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNetImportSchema,
+ selectNetImportSchema
+};
+
+class AddNetImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNetImportSchema();
+ }
+
+ //
+ // Listeners
+
+ onNetImportSelect = ({ implementation, name }) => {
+ this.props.selectNetImportSchema({ implementation, presetName: name });
+ this.props.onModalClose({ netImportSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNetImportModalContentConnector.propTypes = {
+ fetchNetImportSchema: PropTypes.func.isRequired,
+ selectNetImportSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNetImportModalContentConnector);
diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportPresetMenuItem.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportPresetMenuItem.js
new file mode 100644
index 000000000..75f6279cb
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddNetImportPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddNetImportPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddNetImportPresetMenuItem;
diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModal.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModal.js
new file mode 100644
index 000000000..0fd411c5f
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditNetImportModalContentConnector from './EditNetImportModalContentConnector';
+
+function EditNetImportModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditNetImportModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditNetImportModal;
diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalConnector.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalConnector.js
new file mode 100644
index 000000000..18c567af3
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelTestNetImport, cancelSaveNetImport } from 'Store/Actions/settingsActions';
+import EditNetImportModal from './EditNetImportModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.netImports';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestNetImport() {
+ dispatch(cancelTestNetImport({ section }));
+ },
+
+ dispatchCancelSaveNetImport() {
+ dispatch(cancelSaveNetImport({ section }));
+ }
+ };
+}
+
+class EditNetImportModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestNetImport();
+ this.props.dispatchCancelSaveNetImport();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestNetImport,
+ dispatchCancelSaveNetImport,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditNetImportModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestNetImport: PropTypes.func.isRequired,
+ dispatchCancelSaveNetImport: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditNetImportModalConnector);
diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.css b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.js
new file mode 100644
index 000000000..39d0233c2
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.js
@@ -0,0 +1,211 @@
+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 './EditNetImportModalContent.css';
+
+function EditNetImportModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteNetImportPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ enabled,
+ enableAuto,
+ shouldMonitor,
+ qualityProfileId,
+ rootFolderPath,
+ fields
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} List - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new list, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditNetImportModalContent.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,
+ onDeleteNetImportPress: PropTypes.func
+};
+
+export default EditNetImportModalContent;
diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContentConnector.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContentConnector.js
new file mode 100644
index 000000000..374d9f216
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setNetImportValue, setNetImportFieldValue, saveNetImport, testNetImport } from 'Store/Actions/settingsActions';
+import EditNetImportModalContent from './EditNetImportModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('netImports'),
+ (advancedSettings, netImport) => {
+ return {
+ advancedSettings,
+ ...netImport
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setNetImportValue,
+ setNetImportFieldValue,
+ saveNetImport,
+ testNetImport
+};
+
+class EditNetImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNetImportValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setNetImportFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveNetImport({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testNetImport({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditNetImportModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setNetImportValue: PropTypes.func.isRequired,
+ setNetImportFieldValue: PropTypes.func.isRequired,
+ saveNetImport: PropTypes.func.isRequired,
+ testNetImport: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditNetImportModalContentConnector);
diff --git a/frontend/src/Settings/NetImport/NetImport/NetImport.css b/frontend/src/Settings/NetImport/NetImport/NetImport.css
new file mode 100644
index 000000000..92b99ec3b
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/NetImport.css
@@ -0,0 +1,19 @@
+.netImport {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/NetImport/NetImport/NetImport.js b/frontend/src/Settings/NetImport/NetImport/NetImport.js
new file mode 100644
index 000000000..0969d72c7
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/NetImport.js
@@ -0,0 +1,127 @@
+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 EditNetImportModalConnector from './EditNetImportModalConnector';
+import styles from './NetImport.css';
+
+class NetImport extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditNetImportModalOpen: false,
+ isDeleteNetImportModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditNetImportPress = () => {
+ this.setState({ isEditNetImportModalOpen: true });
+ }
+
+ onEditNetImportModalClose = () => {
+ this.setState({ isEditNetImportModalOpen: false });
+ }
+
+ onDeleteNetImportPress = () => {
+ this.setState({
+ isEditNetImportModalOpen: false,
+ isDeleteNetImportModalOpen: true
+ });
+ }
+
+ onDeleteNetImportModalClose= () => {
+ this.setState({ isDeleteNetImportModalOpen: false });
+ }
+
+ onConfirmDeleteNetImport = () => {
+ this.props.onConfirmDeleteNetImport(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enabled,
+ enableAuto
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ {
+ enabled &&
+
+ Enabled
+
+ }
+
+ {
+ enableAuto &&
+
+ Auto
+
+ }
+
+ {
+ !enabled && !enableAuto &&
+
+ Disabled
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+NetImport.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enabled: PropTypes.bool.isRequired,
+ enableAuto: PropTypes.bool.isRequired,
+ onConfirmDeleteNetImport: PropTypes.func.isRequired
+};
+
+export default NetImport;
diff --git a/frontend/src/Settings/NetImport/NetImport/NetImports.css b/frontend/src/Settings/NetImport/NetImport/NetImports.css
new file mode 100644
index 000000000..e7d2854a6
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/NetImports.css
@@ -0,0 +1,20 @@
+.netImports {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addNetImport {
+ composes: netImport from './NetImport.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/NetImport/NetImport/NetImports.js b/frontend/src/Settings/NetImport/NetImport/NetImports.js
new file mode 100644
index 000000000..668f8ccfb
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/NetImports.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import NetImport from './NetImport';
+import AddNetImportModal from './AddNetImportModal';
+import EditNetImportModalConnector from './EditNetImportModalConnector';
+import styles from './NetImports.css';
+
+class NetImports extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNetImportModalOpen: false,
+ isEditNetImportModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddNetImportPress = () => {
+ this.setState({ isAddNetImportModalOpen: true });
+ }
+
+ onAddNetImportModalClose = ({ netImportSelected = false } = {}) => {
+ this.setState({
+ isAddNetImportModalOpen: false,
+ isEditNetImportModalOpen: netImportSelected
+ });
+ }
+
+ onEditNetImportModalClose = () => {
+ this.setState({ isEditNetImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteNetImport,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddNetImportModalOpen,
+ isEditNetImportModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+NetImports.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteNetImport: PropTypes.func.isRequired
+};
+
+export default NetImports;
diff --git a/frontend/src/Settings/NetImport/NetImport/NetImportsConnector.js b/frontend/src/Settings/NetImport/NetImport/NetImportsConnector.js
new file mode 100644
index 000000000..5e922033c
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImport/NetImportsConnector.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 { fetchNetImports, deleteNetImport } from 'Store/Actions/settingsActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import NetImports from './NetImports';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.netImports,
+ (netImports) => {
+ return {
+ ...netImports
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNetImports,
+ deleteNetImport,
+ fetchRootFolders
+};
+
+class NetImportsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNetImports();
+ this.props.fetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteNetImport = (id) => {
+ this.props.deleteNetImport({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NetImportsConnector.propTypes = {
+ fetchNetImports: PropTypes.func.isRequired,
+ deleteNetImport: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NetImportsConnector);
diff --git a/frontend/src/Settings/NetImport/NetImportSettings.js b/frontend/src/Settings/NetImport/NetImportSettings.js
new file mode 100644
index 000000000..9da0370a8
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImportSettings.js
@@ -0,0 +1,99 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import NetImportsConnector from './NetImport/NetImportsConnector';
+import NetImportOptionsConnector from './Options/NetImportOptionsConnector';
+// import ImportExclusionsConnector from './ImportExclusions/ImportExclusionsConnector';
+
+class NetImportSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ // Render
+ //
+
+ render() {
+ const {
+ isTestingAll,
+ dispatchTestAllNetImport
+ } = this.props;
+
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+NetImportSettings.propTypes = {
+ isTestingAll: PropTypes.bool.isRequired,
+ dispatchTestAllNetImport: PropTypes.func.isRequired
+};
+
+export default NetImportSettings;
diff --git a/frontend/src/Settings/NetImport/NetImportSettingsConnector.js b/frontend/src/Settings/NetImport/NetImportSettingsConnector.js
new file mode 100644
index 000000000..0484dc88f
--- /dev/null
+++ b/frontend/src/Settings/NetImport/NetImportSettingsConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllNetImport } from 'Store/Actions/settingsActions';
+import NetImportSettings from './NetImportSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.netImports.isTestingAll,
+ (isTestingAll) => {
+ return {
+ isTestingAll
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchTestAllNetImport: testAllNetImport
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NetImportSettings);
diff --git a/frontend/src/Settings/NetImport/Options/NetImportOptions.js b/frontend/src/Settings/NetImport/Options/NetImportOptions.js
new file mode 100644
index 000000000..8b9c15436
--- /dev/null
+++ b/frontend/src/Settings/NetImport/Options/NetImportOptions.js
@@ -0,0 +1,84 @@
+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 NetImportOptions(props) {
+ const {
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ const cleanLibraryLevelOptions = [
+ { key: 'disabled', value: 'Disabled' },
+ { key: 'logOnly', value: 'Log Only' },
+ { key: 'keepAndUnmonitor', value: 'Keep and Unmonitor' },
+ { key: 'removeAndKeep', value: 'Remove and Keep' },
+ { key: 'removeAndDelete', value: 'Remove and Delete' }
+ ];
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load list options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+}
+
+NetImportOptions.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 NetImportOptions;
diff --git a/frontend/src/Settings/NetImport/Options/NetImportOptionsConnector.js b/frontend/src/Settings/NetImport/Options/NetImportOptionsConnector.js
new file mode 100644
index 000000000..eb2d94913
--- /dev/null
+++ b/frontend/src/Settings/NetImport/Options/NetImportOptionsConnector.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchNetImportOptions, setNetImportOptionsValue, saveNetImportOptions } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import NetImportOptions from './NetImportOptions';
+
+const SECTION = 'netImportOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchNetImportOptions: fetchNetImportOptions,
+ dispatchSetNetImportOptionsValue: setNetImportOptionsValue,
+ dispatchSaveNetImportOptions: saveNetImportOptions,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class NetImportOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchNetImportOptions,
+ dispatchSaveNetImportOptions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchNetImportOptions();
+ onChildMounted(dispatchSaveNetImportOptions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: SECTION });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetNetImportOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NetImportOptionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchNetImportOptions: PropTypes.func.isRequired,
+ dispatchSetNetImportOptionsValue: PropTypes.func.isRequired,
+ dispatchSaveNetImportOptions: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NetImportOptionsConnector);
diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js
new file mode 100644
index 000000000..c9bed6501
--- /dev/null
+++ b/frontend/src/Settings/Notifications/NotificationSettings.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import NotificationsConnector from './Notifications/NotificationsConnector';
+
+function NotificationSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default NotificationSettings;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css
new file mode 100644
index 000000000..e2181150c
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css
@@ -0,0 +1,44 @@
+.notification {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js
new file mode 100644
index 000000000..6d90961b0
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem';
+import styles from './AddNotificationItem.css';
+
+class AddNotificationItem extends Component {
+
+ //
+ // Listeners
+
+ onNotificationSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onNotificationSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onNotificationSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddNotificationItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onNotificationSelect: PropTypes.func.isRequired
+};
+
+export default AddNotificationItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
new file mode 100644
index 000000000..45f5e14b6
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNotificationModalContentConnector from './AddNotificationModalContentConnector';
+
+function AddNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
new file mode 100644
index 000000000..8744e516c
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
@@ -0,0 +1,5 @@
+.notifications {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
new file mode 100644
index 000000000..e09342b98
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import AddNotificationItem from './AddNotificationItem';
+import styles from './AddNotificationModalContent.css';
+
+class AddNotificationModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema,
+ onNotificationSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Notification
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new notification, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+ {
+ schema.map((notification) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddNotificationModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ schema: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNotificationSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js
new file mode 100644
index 000000000..abeb5e2ac
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchNotificationSchema, selectNotificationSchema } from 'Store/Actions/settingsActions';
+import AddNotificationModalContent from './AddNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.notifications,
+ (notifications) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = notifications;
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotificationSchema,
+ selectNotificationSchema
+};
+
+class AddNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotificationSchema();
+ }
+
+ //
+ // Listeners
+
+ onNotificationSelect = ({ implementation, name }) => {
+ this.props.selectNotificationSchema({ implementation, presetName: name });
+ this.props.onModalClose({ notificationSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNotificationModalContentConnector.propTypes = {
+ fetchNotificationSchema: PropTypes.func.isRequired,
+ selectNotificationSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
new file mode 100644
index 000000000..e4df85b8a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddNotificationPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddNotificationPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddNotificationPresetMenuItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
new file mode 100644
index 000000000..27e41d062
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditNotificationModalContentConnector from './EditNotificationModalContentConnector';
+
+function EditNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js
new file mode 100644
index 000000000..e1452d142
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { cancelTestNotification, cancelSaveNotification } from 'Store/Actions/settingsActions';
+import EditNotificationModal from './EditNotificationModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.notifications';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestNotification() {
+ dispatch(cancelTestNotification({ section }));
+ },
+
+ dispatchCancelSaveNotification() {
+ dispatch(cancelSaveNotification({ section }));
+ }
+ };
+}
+
+class EditNotificationModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestNotification();
+ this.props.dispatchCancelSaveNotification();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestNotification,
+ dispatchCancelSaveNotification,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestNotification: PropTypes.func.isRequired,
+ dispatchCancelSaveNotification: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditNotificationModalConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css
new file mode 100644
index 000000000..c73406b57
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css
@@ -0,0 +1,11 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.message {
+ composes: alert from 'Components/Alert.css';
+
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
new file mode 100644
index 000000000..5c08e6622
--- /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,
+ implementationName,
+ name,
+ onGrab,
+ onDownload,
+ onUpgrade,
+ onRename,
+ supportsOnGrab,
+ supportsOnDownload,
+ supportsOnUpgrade,
+ supportsOnRename,
+ tags,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Connection - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new notification, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditNotificationModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteNotificationPress: PropTypes.func
+};
+
+export default EditNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
new file mode 100644
index 000000000..104f1897a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setNotificationValue, setNotificationFieldValue, saveNotification, testNotification } from 'Store/Actions/settingsActions';
+import EditNotificationModalContent from './EditNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('notifications'),
+ (advancedSettings, notification) => {
+ return {
+ advancedSettings,
+ ...notification
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setNotificationValue,
+ setNotificationFieldValue,
+ saveNotification,
+ testNotification
+};
+
+class EditNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNotificationValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setNotificationFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveNotification({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testNotification({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setNotificationValue: PropTypes.func.isRequired,
+ setNotificationFieldValue: PropTypes.func.isRequired,
+ saveNotification: PropTypes.func.isRequired,
+ testNotification: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css b/frontend/src/Settings/Notifications/Notifications/Notification.css
new file mode 100644
index 000000000..5a17a4c20
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.css
@@ -0,0 +1,19 @@
+.notification {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js
new file mode 100644
index 000000000..143e5491a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.js
@@ -0,0 +1,150 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import styles from './Notification.css';
+
+class Notification extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditNotificationPress = () => {
+ this.setState({ isEditNotificationModalOpen: true });
+ }
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ }
+
+ onDeleteNotificationPress = () => {
+ this.setState({
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: true
+ });
+ }
+
+ onDeleteNotificationModalClose= () => {
+ this.setState({ isDeleteNotificationModalOpen: false });
+ }
+
+ onConfirmDeleteNotification = () => {
+ this.props.onConfirmDeleteNotification(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ onGrab,
+ onDownload,
+ onUpgrade,
+ onRename,
+ supportsOnGrab,
+ supportsOnDownload,
+ supportsOnUpgrade,
+ supportsOnRename
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+ {
+ supportsOnGrab && onGrab &&
+
+ On Grab
+
+ }
+
+ {
+ supportsOnDownload && onDownload &&
+
+ On Download
+
+ }
+
+ {
+ supportsOnUpgrade && onDownload && onUpgrade &&
+
+ On Upgrade
+
+ }
+
+ {
+ supportsOnRename && onRename &&
+
+ On Rename
+
+ }
+
+ {
+ !onGrab && !onDownload && !onRename &&
+
+ Disabled
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Notification.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ onGrab: PropTypes.bool.isRequired,
+ onDownload: PropTypes.bool.isRequired,
+ onUpgrade: PropTypes.bool.isRequired,
+ onRename: PropTypes.bool.isRequired,
+ supportsOnGrab: PropTypes.bool.isRequired,
+ supportsOnDownload: PropTypes.bool.isRequired,
+ supportsOnUpgrade: PropTypes.bool.isRequired,
+ supportsOnRename: PropTypes.bool.isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notification;
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css
new file mode 100644
index 000000000..26b890e88
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css
@@ -0,0 +1,20 @@
+.notifications {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addNotification {
+ composes: notification from './Notification.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.js b/frontend/src/Settings/Notifications/Notifications/Notifications.js
new file mode 100644
index 000000000..0296c2ed4
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Notification from './Notification';
+import AddNotificationModal from './AddNotificationModal';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import styles from './Notifications.css';
+
+class Notifications extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddNotificationPress = () => {
+ this.setState({ isAddNotificationModalOpen: true });
+ }
+
+ onAddNotificationModalClose = ({ notificationSelected = false } = {}) => {
+ this.setState({
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: notificationSelected
+ });
+ }
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteNotification,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddNotificationModalOpen,
+ isEditNotificationModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Notifications.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notifications;
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
new file mode 100644
index 000000000..b2b5e5166
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchNotifications, deleteNotification } from 'Store/Actions/settingsActions';
+import Notifications from './Notifications';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.notifications,
+ (notifications) => {
+ return {
+ ...notifications
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotifications,
+ deleteNotification
+};
+
+class NotificationsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotifications();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteNotification = (id) => {
+ this.props.deleteNotification({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NotificationsConnector.propTypes = {
+ fetchNotifications: PropTypes.func.isRequired,
+ deleteNotification: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NotificationsConnector);
diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js
new file mode 100644
index 000000000..e3b14e228
--- /dev/null
+++ b/frontend/src/Settings/PendingChangesModal.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function PendingChangesModal(props) {
+ const {
+ isOpen,
+ onConfirm,
+ onCancel
+ } = props;
+
+ return (
+
+
+ Unsaved Changes
+
+
+ You have unsaved changes, are you sure you want to leave this page?
+
+
+
+
+ Stay and review changes
+
+
+
+ Discard changes and leave
+
+
+
+
+ );
+}
+
+PendingChangesModal.propTypes = {
+ className: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all),
+ onConfirm: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired
+};
+
+PendingChangesModal.defaultProps = {
+ kind: kinds.PRIMARY
+};
+
+export default PendingChangesModal;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
new file mode 100644
index 000000000..238742efd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
@@ -0,0 +1,40 @@
+.delayProfile {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid $borderColor;
+ line-height: 30px;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.actions {
+ display: flex;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.editButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
new file mode 100644
index 000000000..d72a06467
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
@@ -0,0 +1,172 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import titleCase from 'Utilities/String/titleCase';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import TagList from 'Components/TagList';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfile.css';
+
+function getDelay(enabled, delay) {
+ if (!enabled) {
+ return '-';
+ }
+
+ if (!delay) {
+ return 'No Delay';
+ }
+
+ if (delay === 1) {
+ return '1 Minute';
+ }
+
+ // TODO: use better units of time than just minutes
+ return `${delay} Minutes`;
+}
+
+class DelayProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditDelayProfilePress = () => {
+ this.setState({ isEditDelayProfileModalOpen: true });
+ }
+
+ onEditDelayProfileModalClose = () => {
+ this.setState({ isEditDelayProfileModalOpen: false });
+ }
+
+ onDeleteDelayProfilePress = () => {
+ this.setState({
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: true
+ });
+ }
+
+ onDeleteDelayProfileModalClose = () => {
+ this.setState({ isDeleteDelayProfileModalOpen: false });
+ }
+
+ onConfirmDeleteDelayProfile = () => {
+ this.props.onConfirmDeleteDelayProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ enableUsenet,
+ enableTorrent,
+ preferredProtocol,
+ usenetDelay,
+ torrentDelay,
+ tags,
+ tagList,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ let preferred = titleCase(preferredProtocol);
+
+ if (!enableUsenet) {
+ preferred = 'Only Torrent';
+ } else if (!enableTorrent) {
+ preferred = 'Only Usenet';
+ }
+
+ return (
+
+
{preferred}
+
{getDelay(enableUsenet, usenetDelay)}
+
{getDelay(enableTorrent, torrentDelay)}
+
+
+
+
+
+
+
+
+ {
+ id !== 1 &&
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ enableUsenet: PropTypes.bool.isRequired,
+ enableTorrent: PropTypes.bool.isRequired,
+ preferredProtocol: PropTypes.string.isRequired,
+ usenetDelay: PropTypes.number.isRequired,
+ torrentDelay: PropTypes.number.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+DelayProfile.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default DelayProfile;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
new file mode 100644
index 000000000..cc5a92830
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
@@ -0,0 +1,3 @@
+.dragPreview {
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
new file mode 100644
index 000000000..402ddcc13
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragPreview.css';
+
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class DelayProfileDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ width,
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== DELAY_PROFILE) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = width - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ width,
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfileDragPreview.propTypes = {
+ width: PropTypes.number.isRequired,
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(DelayProfileDragPreview);
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
new file mode 100644
index 000000000..835250678
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
@@ -0,0 +1,17 @@
+.delayProfileDragSource {
+ padding: 4px 0;
+}
+
+.delayProfilePlaceholder {
+ width: 100%;
+ height: 30px;
+ border-bottom: 1px dotted #aaa;
+}
+
+.delayProfilePlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.delayProfilePlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
new file mode 100644
index 000000000..5c1c565e0
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragSource.css';
+
+const delayProfileDragSource = {
+ beginDrag(item) {
+ return item;
+ },
+
+ endDrag(props, monitor, component) {
+ props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const delayProfileDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().order;
+ const hoverIndex = props.order;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ // When moving up, only trigger if drag position is above 50% and
+ // when moving down, only trigger if drag position is below 50%.
+ // If we're moving down the hoverIndex needs to be increased
+ // by one so it's ordered properly. Otherwise the hoverIndex will work.
+
+ if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex + 1);
+ } else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex);
+ }
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class DelayProfileDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ order,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ ...otherProps
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+DelayProfileDragSource.propTypes = {
+ id: PropTypes.number.isRequired,
+ order: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onDelayProfileDragMove: PropTypes.func.isRequired,
+ onDelayProfileDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ DELAY_PROFILE,
+ delayProfileDropTarget,
+ collectDropTarget
+)(DragSource(
+ DELAY_PROFILE,
+ delayProfileDragSource,
+ collectDragSource
+)(DelayProfileDragSource));
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
new file mode 100644
index 000000000..3cf3e9020
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
@@ -0,0 +1,27 @@
+.delayProfiles {
+ user-select: none;
+}
+
+.delayProfilesHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.tags {
+ flex: 1 0 auto;
+}
+
+.addDelayProfile {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.addButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
new file mode 100644
index 000000000..a745da9d4
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import Measure from 'Components/Measure';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import DelayProfileDragSource from './DelayProfileDragSource';
+import DelayProfileDragPreview from './DelayProfileDragPreview';
+import DelayProfile from './DelayProfile';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfiles.css';
+
+class DelayProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddDelayProfileModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddDelayProfilePress = () => {
+ this.setState({ isAddDelayProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddDelayProfileModalOpen: false });
+ }
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ defaultProfile,
+ items,
+ tagList,
+ dragIndex,
+ dropIndex,
+ onConfirmDeleteDelayProfile,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddDelayProfileModalOpen,
+ width
+ } = this.state;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex < dragIndex;
+ const isDraggingDown = isDragging && dropIndex > dragIndex;
+
+ return (
+
+
+
+
+
Protocol
+
Usenet Delay
+
Torrent Delay
+
Tags
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ {
+ defaultProfile &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+export default DelayProfiles;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
new file mode 100644
index 000000000..16fe5718c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
@@ -0,0 +1,105 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDelayProfiles, deleteDelayProfile, reorderDelayProfile } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import DelayProfiles from './DelayProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.delayProfiles,
+ createTagsSelector(),
+ (delayProfiles, tagList) => {
+ const defaultProfile = _.find(delayProfiles.items, { id: 1 });
+ const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']);
+
+ return {
+ defaultProfile,
+ ...delayProfiles,
+ items,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDelayProfiles,
+ deleteDelayProfile,
+ reorderDelayProfile
+};
+
+class DelayProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchDelayProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteDelayProfile = (id) => {
+ this.props.deleteDelayProfile({ id });
+ }
+
+ onDelayProfileDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onDelayProfileDragEnd = ({ id }, didDrop) => {
+ const {
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DelayProfilesConnector.propTypes = {
+ fetchDelayProfiles: PropTypes.func.isRequired,
+ deleteDelayProfile: PropTypes.func.isRequired,
+ reorderDelayProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
new file mode 100644
index 000000000..9444fd65e
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector';
+
+function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditDelayProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditDelayProfileModal;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
new file mode 100644
index 000000000..a1e8d2dcd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditDelayProfileModal from './EditDelayProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditDelayProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.delayProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
new file mode 100644
index 000000000..8960cd2b7
--- /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..5b7e036f5
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js
@@ -0,0 +1,178 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setDelayProfileValue, saveDelayProfile } from 'Store/Actions/settingsActions';
+import EditDelayProfileModalContent from './EditDelayProfileModalContent';
+
+const newDelayProfile = {
+ enableUsenet: true,
+ enableTorrent: true,
+ preferredProtocol: 'usenet',
+ usenetDelay: 0,
+ torrentDelay: 0,
+ tags: []
+};
+
+const protocolOptions = [
+ { key: 'preferUsenet', value: 'Prefer Usenet' },
+ { key: 'preferTorrent', value: 'Prefer Torrent' },
+ { key: 'onlyUsenet', value: 'Only Usenet' },
+ { key: 'onlyTorrent', value: 'Only Torrent' }
+];
+
+function createDelayProfileSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.delayProfiles,
+ (id, delayProfiles) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = delayProfiles;
+
+ const profile = id ? _.find(items, { id }) : newDelayProfile;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createDelayProfileSelector(),
+ (delayProfile) => {
+ const enableUsenet = delayProfile.item.enableUsenet.value;
+ const enableTorrent = delayProfile.item.enableTorrent.value;
+ const preferredProtocol = delayProfile.item.preferredProtocol.value;
+ let protocol = 'preferUsenet';
+
+ if (preferredProtocol === 'usenet') {
+ protocol = 'preferUsenet';
+ } else {
+ protocol = 'preferTorrent';
+ }
+
+ if (!enableUsenet) {
+ protocol = 'onlyTorrent';
+ }
+
+ if (!enableTorrent) {
+ protocol = 'onlyUsenet';
+ }
+
+ return {
+ protocol,
+ protocolOptions,
+ ...delayProfile
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDelayProfileValue,
+ saveDelayProfile
+};
+
+class EditDelayProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newDelayProfile).forEach((name) => {
+ this.props.setDelayProfileValue({
+ name,
+ value: newDelayProfile[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDelayProfileValue({ name, value });
+ }
+
+ onProtocolChange = ({ value }) => {
+ switch (value) {
+ case 'preferUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'preferTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ case 'onlyUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: false });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'onlyTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: false });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ default:
+ throw Error(`Unknown protocol option: ${value}`);
+ }
+ }
+
+ onSavePress = () => {
+ this.props.saveDelayProfile({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDelayProfileValue: PropTypes.func.isRequired,
+ saveDelayProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js
new file mode 100644
index 000000000..0479ccd69
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Profiles.js
@@ -0,0 +1,34 @@
+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 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..9ecbd1ca8
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
+
+class EditQualityProfileModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ height: 'auto'
+ };
+ }
+
+ //
+ // Listeners
+
+ onContentHeightChange = (height) => {
+ if (this.state.height === 'auto' || height > this.state.height) {
+ this.setState({ height });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+EditQualityProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditQualityProfileModal;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
new file mode 100644
index 000000000..942949cac
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditQualityProfileModal from './EditQualityProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditQualityProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.qualityProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
new file mode 100644
index 000000000..2f6589933
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
@@ -0,0 +1,18 @@
+.formGroupsContainer {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.formGroupWrapper {
+ flex: 0 0 calc($formGroupSmallWidth - 100px);
+}
+
+.deleteButtonContainer {
+ margin-right: auto;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .formGroupsContainer {
+ display: block;
+ }
+}
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
new file mode 100644
index 000000000..e390f251d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
@@ -0,0 +1,252 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Measure from 'Components/Measure';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import QualityProfileItems from './QualityProfileItems';
+import styles from './EditQualityProfileModalContent.css';
+
+const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
+
+class EditQualityProfileModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ headerHeight: 0,
+ bodyHeight: 0,
+ footerHeight: 0
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ headerHeight,
+ bodyHeight,
+ footerHeight
+ } = this.state;
+
+ if (
+ headerHeight > 0 &&
+ bodyHeight > 0 &&
+ footerHeight > 0 &&
+ (
+ headerHeight !== prevState.headerHeight ||
+ bodyHeight !== prevState.bodyHeight ||
+ footerHeight !== prevState.footerHeight
+ )
+ ) {
+ const padding = MODAL_BODY_PADDING * 2;
+
+ this.props.onContentHeightChange(
+ headerHeight + bodyHeight + footerHeight + padding
+ );
+ }
+ }
+
+ //
+ // Listeners
+
+ onHeaderMeasure = ({ height }) => {
+ if (height > this.state.headerHeight) {
+ this.setState({ headerHeight: height });
+ }
+ }
+
+ onBodyMeasure = ({ height }) => {
+
+ if (height > this.state.bodyHeight) {
+ this.setState({ bodyHeight: height });
+ }
+ }
+
+ onFooterMeasure = ({ height }) => {
+ if (height > this.state.footerHeight) {
+ this.setState({ footerHeight: height });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ qualities,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteQualityProfilePress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ name,
+ cutoff,
+ items
+ } = item;
+
+ return (
+
+
+
+ {id ? 'Edit Quality Profile' : 'Add Quality Profile'}
+
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
Unable to add a new quality profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+
+ }
+
+
+
+
+
+
+ {
+ id &&
+
+
+ Delete
+
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+ );
+ }
+}
+
+EditQualityProfileModalContent.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onCutoffChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onContentHeightChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteQualityProfilePress: PropTypes.func
+};
+
+export default EditQualityProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
new file mode 100644
index 000000000..ea93ed2dd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
@@ -0,0 +1,442 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions';
+import EditQualityProfileModalContent from './EditQualityProfileModalContent';
+
+function getQualityItemGroupId(qualityProfile) {
+ // Get items with an `id` and filter out null/undefined values
+ const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
+
+ return Math.max(1000, ...ids) + 1;
+}
+
+function parseIndex(index) {
+ const split = index.split('.');
+
+ if (split.length === 1) {
+ return [
+ null,
+ parseInt(split[0]) - 1
+ ];
+ }
+
+ return [
+ parseInt(split[0]) - 1,
+ parseInt(split[1]) - 1
+ ];
+}
+
+function createQualitiesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ (qualityProfile) => {
+ const items = qualityProfile.item.items;
+ if (!items || !items.value) {
+ return [];
+ }
+
+ return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
+ if (allowed) {
+ if (id) {
+ result.push({
+ key: id,
+ value: name
+ });
+ } else {
+ result.push({
+ key: quality.id,
+ value: quality.name
+ });
+ }
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ createQualitiesSelector(),
+ createProfileInUseSelector('qualityProfileId'),
+ (qualityProfile, qualities, isInUse) => {
+ return {
+ qualities,
+ ...qualityProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ setQualityProfileValue,
+ saveQualityProfile
+};
+
+class EditQualityProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null,
+ editGroups: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id && !this.props.isPopulated) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Control
+
+ ensureCutoff = (qualityProfile) => {
+ const cutoff = qualityProfile.cutoff.value;
+
+ const cutoffItem = _.find(qualityProfile.items.value, (i) => {
+ if (!cutoff) {
+ return false;
+ }
+
+ return i.id === cutoff.id || (i.quality && i.quality.id === cutoff.id);
+ });
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
+ const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
+ let cutoffId = null;
+
+ if (firstAllowed) {
+ cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
+ }
+
+ this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setQualityProfileValue({ name, value });
+ }
+
+ onCutoffChange = ({ name, value }) => {
+ const id = parseInt(value);
+ const item = _.find(this.props.item.items.value, (i) => {
+ if (i.quality) {
+ return i.quality.id === id;
+ }
+
+ return i.id === id;
+ });
+
+ const cutoffId = item.quality ? item.quality.id : item.id;
+
+ this.props.setQualityProfileValue({ name, value: cutoffId });
+ }
+
+ onSavePress = () => {
+ this.props.saveQualityProfile({ id: this.props.id });
+ }
+
+ onQualityProfileItemAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
+
+ item.allowed = allowed;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onItemGroupAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.id === id);
+
+ item.allowed = allowed;
+
+ // Update each item in the group (for consistency only)
+ item.items.forEach((i) => {
+ i.allowed = allowed;
+ });
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onItemGroupNameChange = (id, name) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+
+ group.name = name;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+ }
+
+ onCreateGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(items, (i) => i.quality && i.quality.id === id);
+ const index = items.indexOf(item);
+ const groupId = getQualityItemGroupId(qualityProfile);
+
+ const group = {
+ id: groupId,
+ name: item.quality.name,
+ allowed: item.allowed,
+ items: [
+ item
+ ]
+ };
+
+ // Add the group in the same location the quality item was in.
+ items.splice(index, 1, group);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onDeleteGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+ const index = items.indexOf(group);
+
+ // Add the items in the same location the group was in
+ items.splice(index, 1, ...group.items);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onQualityProfileItemDragMove = (options) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex,
+ dropPosition
+ } = options;
+
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ if (
+ (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
+ (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
+ ) {
+ if (
+ this.state.dragQualityIndex != null &&
+ this.state.dropQualityIndex != null &&
+ this.state.dropPosition != null
+ ) {
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ }
+
+ return;
+ }
+
+ let adjustedDropQualityIndex = dropQualityIndex;
+
+ // Correct dragging out of a group to the position above
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex != null
+ ) {
+ // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting above outside a group
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex == null
+ ) {
+ // Add 2 to the item index so it's entered in the correct place
+ adjustedDropQualityIndex = `${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting below a quality within the same group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex != null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Add 1 to the group index leave the item index
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
+ }
+
+ // Correct inserting below a quality outside a group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex == null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Leave the item index so it's inserted below the item
+ adjustedDropQualityIndex = `${dropItemIndex}`;
+ }
+
+ if (
+ dragQualityIndex !== this.state.dragQualityIndex ||
+ adjustedDropQualityIndex !== this.state.dropQualityIndex ||
+ dropPosition !== this.state.dropPosition
+ ) {
+ this.setState({
+ dragQualityIndex,
+ dropQualityIndex: adjustedDropQualityIndex,
+ dropPosition
+ });
+ }
+ }
+
+ onQualityProfileItemDragEnd = (didDrop) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex
+ } = this.state;
+
+ if (didDrop && dropQualityIndex != null) {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ let item = null;
+ let dropGroup = null;
+
+ // Get the group before moving anything so we know the correct place to drop it.
+ if (dropGroupIndex != null) {
+ dropGroup = items[dropGroupIndex];
+ }
+
+ if (dragGroupIndex == null) {
+ item = items.splice(dragItemIndex, 1)[0];
+ } else {
+ const group = items[dragGroupIndex];
+ item = group.items.splice(dragItemIndex, 1)[0];
+
+ // If the group is now empty, destroy it.
+ if (!group.items.length) {
+ items.splice(dragGroupIndex, 1);
+ }
+ }
+
+ if (dropGroupIndex == null) {
+ items.splice(dropItemIndex, 0, item);
+ } else {
+ dropGroup.items.splice(dropItemIndex, 0, item);
+ }
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ }
+
+ onToggleEditGroupsMode = () => {
+ this.setState({ editGroups: !this.state.editGroups });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setQualityProfileValue: PropTypes.func.isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ saveQualityProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
new file mode 100644
index 000000000..341d75aa2
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
@@ -0,0 +1,38 @@
+.qualityProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.qualities {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
+
+.tooltipLabel {
+ composes: label from 'Components/Label.css';
+
+ margin: 0;
+ border: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
new file mode 100644
index 000000000..65da9fa3b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
@@ -0,0 +1,184 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfile.css';
+
+class QualityProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isEditQualityProfileModalOpen: true });
+ }
+
+ onEditQualityProfileModalClose = () => {
+ this.setState({ isEditQualityProfileModalOpen: false });
+ }
+
+ onDeleteQualityProfilePress = () => {
+ this.setState({
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: true
+ });
+ }
+
+ onDeleteQualityProfileModalClose = () => {
+ this.setState({ isDeleteQualityProfileModalOpen: false });
+ }
+
+ onConfirmDeleteQualityProfile = () => {
+ this.props.onConfirmDeleteQualityProfile(this.props.id);
+ }
+
+ onCloneQualityProfilePress = () => {
+ const {
+ id,
+ onCloneQualityProfilePress
+ } = this.props;
+
+ onCloneQualityProfilePress(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ cutoff,
+ items,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ items.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ if (item.quality) {
+ const isCutoff = item.quality.id === cutoff.id;
+
+ return (
+
+ {item.quality.name}
+
+ );
+ }
+
+ const isCutoff = item.id === cutoff.id;
+
+ return (
+
+ {item.name}
+
+ }
+ tooltip={
+
+ {
+ item.items.map((groupItem) => {
+ return (
+
+ {groupItem.quality.name}
+
+ );
+ })
+ }
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ />
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ cutoff: PropTypes.object.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfile;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
new file mode 100644
index 000000000..7e6370ff8
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
@@ -0,0 +1,85 @@
+.qualityProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+
+ &.isInGroup {
+ border-style: dashed;
+ }
+}
+
+.checkInputContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 5px;
+ margin-left: 8px;
+}
+
+.checkInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 5px;
+}
+
+.qualityNameContainer {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: $qualityProfileItemHeight;
+ cursor: pointer;
+}
+
+.qualityName {
+ &.isInGroup {
+ margin-left: 14px;
+ }
+
+ &.notAllowed {
+ color: #c6c6c6;
+ }
+}
+
+.createGroupButton {
+ composes: buton from 'Components/Link/IconButton.css';
+
+ display: flex;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-right: 5px;
+ margin-left: 8px;
+ width: 20px;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.isPreview {
+ .qualityName {
+ margin-left: 14px;
+
+ &.isInGroup {
+ margin-left: 28px;
+ }
+ }
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
new file mode 100644
index 000000000..8161e7061
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
@@ -0,0 +1,131 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './QualityProfileItem.css';
+
+class QualityProfileItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ qualityId,
+ onQualityProfileItemAllowedChange
+ } = this.props;
+
+ onQualityProfileItemAllowedChange(qualityId, value);
+ }
+
+ onCreateGroupPress = () => {
+ const {
+ qualityId,
+ onCreateGroupPress
+ } = this.props;
+
+ onCreateGroupPress(qualityId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ isPreview,
+ groupId,
+ name,
+ allowed,
+ isDragging,
+ isOverCurrent,
+ connectDragSource
+ } = this.props;
+
+ return (
+
+
+ {
+ editGroups && !groupId && !isPreview &&
+
+ }
+
+ {
+ !editGroups &&
+
+ }
+
+
+ {name}
+
+
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+QualityProfileItem.propTypes = {
+ editGroups: PropTypes.bool,
+ isPreview: PropTypes.bool,
+ groupId: PropTypes.number,
+ qualityId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ isOverCurrent: PropTypes.bool.isRequired,
+ isInGroup: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ onCreateGroupPress: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func
+};
+
+QualityProfileItem.defaultProps = {
+ isPreview: false,
+ isOverCurrent: false,
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default QualityProfileItem;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
new file mode 100644
index 000000000..e0c6e8e8c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import QualityProfileItem from './QualityProfileItem';
+import styles from './QualityProfileItemDragPreview.css';
+
+const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth);
+const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class QualityProfileItemDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ const {
+ editGroups,
+ groupId,
+ qualityId,
+ name,
+ allowed
+ } = item;
+
+ // TODO: Show a different preview for groups
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItemDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
new file mode 100644
index 000000000..d5061cc95
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
@@ -0,0 +1,18 @@
+.qualityProfileItemDragSource {
+ padding: $qualityProfileItemDragSourcePadding 0;
+}
+
+.qualityProfileItemPlaceholder {
+ width: 100%;
+ height: $qualityProfileItemHeight;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.qualityProfileItemPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.qualityProfileItemPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
new file mode 100644
index 000000000..0e1838eb3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
@@ -0,0 +1,241 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import QualityProfileItem from './QualityProfileItem';
+import QualityProfileItemGroup from './QualityProfileItemGroup';
+import styles from './QualityProfileItemDragSource.css';
+
+const qualityProfileItemDragSource = {
+ beginDrag(props) {
+ const {
+ editGroups,
+ qualityIndex,
+ groupId,
+ qualityId,
+ name,
+ allowed
+ } = props;
+
+ return {
+ editGroups,
+ qualityIndex,
+ groupId,
+ qualityId,
+ isGroup: !qualityId,
+ name,
+ allowed
+ };
+ },
+
+ endDrag(props, monitor, component) {
+ props.onQualityProfileItemDragEnd(monitor.didDrop());
+ }
+};
+
+const qualityProfileItemDropTarget = {
+ hover(props, monitor, component) {
+ const {
+ qualityIndex: dragQualityIndex,
+ isGroup: isDragGroup
+ } = monitor.getItem();
+
+ const dropQualityIndex = props.qualityIndex;
+ const isDropGroupItem = !!(props.qualityId && props.groupId);
+
+ // Use childNodeIndex to select the correct node to get the middle of so
+ // we don't bounce between above and below causing rapid setState calls.
+ const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0;
+ const componentDOMNode = findDOMNode(component).children[childNodeIndex];
+ const hoverBoundingRect = componentDOMNode.getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ // If we're hovering over a child don't trigger on the parent
+ if (!monitor.isOver({ shallow: true })) {
+ return;
+ }
+
+ // Don't show targets for dropping on self
+ if (dragQualityIndex === dropQualityIndex) {
+ return;
+ }
+
+ // Don't allow a group to be dropped inside a group
+ if (isDragGroup && isDropGroupItem) {
+ return;
+ }
+
+ let dropPosition = null;
+
+ // Determine drop position based on position over target
+ if (hoverClientY > hoverMiddleY) {
+ dropPosition = 'below';
+ } else if (hoverClientY < hoverMiddleY) {
+ dropPosition = 'above';
+ } else {
+ return;
+ }
+
+ props.onQualityProfileItemDragMove({
+ dragQualityIndex,
+ dropQualityIndex,
+ dropPosition
+ });
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver(),
+ isOverCurrent: monitor.isOver({ shallow: true })
+ };
+}
+
+class QualityProfileItemDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ groupId,
+ qualityId,
+ name,
+ allowed,
+ items,
+ qualityIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOverCurrent,
+ connectDragSource,
+ connectDropTarget,
+ onCreateGroupPress,
+ onDeleteGroupPress,
+ onQualityProfileItemAllowedChange,
+ onItemGroupAllowedChange,
+ onItemGroupNameChange,
+ onQualityProfileItemDragMove,
+ onQualityProfileItemDragEnd
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOverCurrent;
+ const isAfter = !isDragging && isDraggingDown && isOverCurrent;
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+ {
+ !!groupId && qualityId == null &&
+
+ }
+
+ {
+ qualityId != null &&
+
+ }
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemDragSource.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ groupId: PropTypes.number,
+ qualityId: PropTypes.number,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ qualityIndex: PropTypes.string.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOverCurrent: PropTypes.bool,
+ isInGroup: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onCreateGroupPress: PropTypes.func,
+ onDeleteGroupPress: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ onItemGroupAllowedChange: PropTypes.func,
+ onItemGroupNameChange: PropTypes.func,
+ onQualityProfileItemDragMove: PropTypes.func.isRequired,
+ onQualityProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDropTarget,
+ collectDropTarget
+)(DragSource(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDragSource,
+ collectDragSource
+)(QualityProfileItemDragSource));
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css
new file mode 100644
index 000000000..d0720cb6a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css
@@ -0,0 +1,105 @@
+.qualityProfileItemGroup {
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+
+ &.editGroups {
+ background: #fcfcfc;
+ }
+}
+
+.qualityProfileItemGroupInfo {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
+.checkInputContainer {
+ composes: checkInputContainer from './QualityProfileItem.css';
+
+ display: flex;
+ align-items: center;
+}
+
+.checkInput {
+ composes: checkInput from './QualityProfileItem.css';
+}
+
+.nameInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ margin-top: 4px;
+ margin-right: 10px;
+}
+
+.nameContainer {
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+}
+
+.name {
+ flex-shrink: 0;
+
+ &.notAllowed {
+ color: #c6c6c6;
+ }
+}
+
+.groupQualities {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+ flex-wrap: wrap;
+ margin: 2px 0 2px 10px;
+}
+
+.qualityNameContainer {
+ display: flex;
+ align-items: stretch;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+}
+
+.qualityNameLabel {
+ composes: qualityNameContainer;
+
+ cursor: pointer;
+}
+
+.deleteGroupButton {
+ composes: buton from 'Components/Link/IconButton.css';
+
+ display: flex;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-right: 5px;
+ margin-left: 8px;
+ width: 20px;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.items {
+ margin: 0 50px 0 35px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js
new file mode 100644
index 000000000..34008b1ec
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js
@@ -0,0 +1,200 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import CheckInput from 'Components/Form/CheckInput';
+import TextInput from 'Components/Form/TextInput';
+import QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import styles from './QualityProfileItemGroup.css';
+
+class QualityProfileItemGroup extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ groupId,
+ onItemGroupAllowedChange
+ } = this.props;
+
+ onItemGroupAllowedChange(groupId, value);
+ }
+
+ onNameChange = ({ value }) => {
+ const {
+ groupId,
+ onItemGroupNameChange
+ } = this.props;
+
+ onItemGroupNameChange(groupId, value);
+ }
+
+ onDeleteGroupPress = ({ value }) => {
+ const {
+ groupId,
+ onDeleteGroupPress
+ } = this.props;
+
+ onDeleteGroupPress(groupId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ groupId,
+ name,
+ allowed,
+ items,
+ qualityIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ connectDragSource,
+ onQualityProfileItemAllowedChange,
+ onQualityProfileItemDragMove,
+ onQualityProfileItemDragEnd
+ } = this.props;
+
+ return (
+
+
+ {
+ editGroups &&
+
+
+
+
+
+ }
+
+ {
+ !editGroups &&
+
+
+
+
+
+ {name}
+
+
+
+ {
+ items.map(({ quality }) => {
+ return (
+
+ {quality.name}
+
+ );
+ }).reverse()
+ }
+
+
+
+ }
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+ {
+ editGroups &&
+
+ {
+ items.map(({ quality }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemGroup.propTypes = {
+ editGroups: PropTypes.bool,
+ groupId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualityIndex: PropTypes.string.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ isDraggingUp: PropTypes.bool.isRequired,
+ isDraggingDown: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onItemGroupAllowedChange: PropTypes.func.isRequired,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ onItemGroupNameChange: PropTypes.func.isRequired,
+ onDeleteGroupPress: PropTypes.func.isRequired,
+ onQualityProfileItemDragMove: PropTypes.func.isRequired,
+ onQualityProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+QualityProfileItemGroup.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default QualityProfileItemGroup;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
new file mode 100644
index 000000000..6b4268de9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
@@ -0,0 +1,15 @@
+.editGroupsButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-top: 10px;
+}
+
+.editGroupsButtonIcon {
+ margin-right: 8px;
+}
+
+.qualities {
+ margin-top: 10px;
+ transition: min-height 200ms;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
new file mode 100644
index 000000000..c41d4b77d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
@@ -0,0 +1,181 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import Measure from 'Components/Measure';
+import QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
+import styles from './QualityProfileItems.css';
+
+class QualityProfileItems extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ qualitiesHeight: 0,
+ qualitiesHeightEditGroups: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ height }) => {
+ if (this.props.editGroups) {
+ this.setState({
+ qualitiesHeightEditGroups: height
+ });
+ } else {
+ this.setState({ qualitiesHeight: height });
+ }
+ }
+
+ onToggleEditGroupsMode = () => {
+ this.props.onToggleEditGroupsMode();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ dropQualityIndex,
+ dropPosition,
+ qualityProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ const {
+ qualitiesHeight,
+ qualitiesHeightEditGroups
+ } = this.state;
+
+ const isDragging = dropQualityIndex !== null;
+ const isDraggingUp = isDragging && dropPosition === 'above';
+ const isDraggingDown = isDragging && dropPosition === 'below';
+ const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
+
+ return (
+
+
+ Qualities
+
+
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ editGroups ? 'Done Editing Groups' : 'Edit Groups'
+ }
+
+
+
+
+
+ {
+ qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => {
+ const identifier = quality ? quality.id : id;
+
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItems.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ dragQualityIndex: PropTypes.string,
+ dropQualityIndex: PropTypes.string,
+ dropPosition: PropTypes.string,
+ qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ onToggleEditGroupsMode: PropTypes.func.isRequired
+};
+
+QualityProfileItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default QualityProfileItems;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
new file mode 100644
index 000000000..bf13815ff
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ createQualityProfileSelector(),
+ (qualityProfile) => {
+ return {
+ name: qualityProfile.name
+ };
+ }
+ );
+}
+
+function QualityProfileNameConnector({ name, ...otherProps }) {
+ return (
+
+ {name}
+
+ );
+}
+
+QualityProfileNameConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(QualityProfileNameConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
new file mode 100644
index 000000000..9644a7c2d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
@@ -0,0 +1,21 @@
+.qualityProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addQualityProfile {
+ composes: qualityProfile from './QualityProfile.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
new file mode 100644
index 000000000..cf1a21422
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
@@ -0,0 +1,107 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import QualityProfile from './QualityProfile';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfiles.css';
+
+class QualityProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCloneQualityProfilePress = (id) => {
+ this.props.onCloneQualityProfilePress(id);
+ this.setState({ isQualityProfileModalOpen: true });
+ }
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isQualityProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isQualityProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteQualityProfile,
+ onCloneQualityProfilePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfiles;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
new file mode 100644
index 000000000..c7596ad63
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions';
+import QualityProfiles from './QualityProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ return {
+ ...qualityProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityProfiles: fetchQualityProfiles,
+ dispatchDeleteQualityProfile: deleteQualityProfile,
+ dispatchCloneQualityProfile: cloneQualityProfile
+};
+
+class QualityProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchQualityProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteQualityProfile = (id) => {
+ this.props.dispatchDeleteQualityProfile({ id });
+ }
+
+ onCloneQualityProfilePress = (id) => {
+ this.props.dispatchCloneQualityProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfilesConnector.propTypes = {
+ dispatchFetchQualityProfiles: PropTypes.func.isRequired,
+ dispatchDeleteQualityProfile: PropTypes.func.isRequired,
+ dispatchCloneQualityProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
new file mode 100644
index 000000000..ccfd00c7a
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
@@ -0,0 +1,93 @@
+.qualityDefinition {
+ display: flex;
+ align-content: stretch;
+ margin: 5px 0;
+ padding-top: 5px;
+ height: 45px;
+ border-top: 1px solid $borderColor;
+}
+
+.quality,
+.title {
+ flex: 0 1 250px;
+ padding-right: 20px;
+ line-height: 40px;
+}
+
+.sizeLimit {
+ flex: 0 1 500px;
+ padding-right: 30px;
+}
+
+.slider {
+ width: 100%;
+ height: 20px;
+}
+
+.bar {
+ top: 9px;
+ margin: 0 5px;
+ height: 3px;
+ background-color: $sliderAccentColor;
+ box-shadow: 0 0 0 #000;
+
+ &:nth-child(odd) {
+ background-color: #ddd;
+ }
+}
+
+.handle {
+ top: 1px;
+ z-index: 0 !important;
+ width: 18px;
+ height: 18px;
+ border: 3px solid $sliderAccentColor;
+ border-radius: 50%;
+ background-color: $white;
+ text-align: center;
+ cursor: pointer;
+}
+
+.sizes {
+ display: flex;
+ justify-content: space-between;
+}
+
+.megabytesPerMinute {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 250px;
+}
+
+.sizeInput {
+ composes: input from 'Components/Form/TextInput.css';
+
+ display: inline-block;
+ margin-left: 5px;
+ padding: 6px;
+ width: 75px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .qualityDefinition {
+ flex-wrap: wrap;
+ height: auto;
+
+ &:first-child {
+ border-top: none;
+ }
+ }
+
+ .qualityDefinition:first-child {
+ border-top: none;
+ }
+
+ .quality {
+ font-weight: bold;
+ line-height: inherit;
+ }
+
+ .sizeLimit {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
new file mode 100644
index 000000000..41af7cf1c
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
@@ -0,0 +1,242 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactSlider from 'react-slider';
+import formatBytes from 'Utilities/Number/formatBytes';
+import roundNumber from 'Utilities/Number/roundNumber';
+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 MIN = 0;
+const MAX = 400;
+
+const slider = {
+ min: MIN,
+ max: roundNumber(Math.pow(MAX, 1 / 1.1)),
+ step: 0.1
+};
+
+function getValue(inputValue) {
+ if (inputValue < MIN) {
+ return MIN;
+ }
+
+ if (inputValue > MAX) {
+ return MAX;
+ }
+
+ return roundNumber(inputValue);
+}
+
+function getSliderValue(value, defaultValue) {
+ const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue;
+
+ return roundNumber(sliderValue);
+}
+
+class QualityDefinition extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._forceUpdateTimeout = null;
+
+ this.state = {
+ sliderMinSize: getSliderValue(props.minSize, slider.min),
+ sliderMaxSize: getSliderValue(props.maxSize, slider.max)
+ };
+ }
+
+ componentDidMount() {
+ // A hack to deal with a bug in the slider component until a fix for it
+ // lands and an updated version is available.
+ // See: https://github.com/mpowaga/react-slider/issues/115
+
+ this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1);
+ }
+
+ componentWillUnmount() {
+ if (this._forceUpdateTimeout) {
+ clearTimeout(this._forceUpdateTimeout);
+ }
+ }
+
+ //
+ // Listeners
+
+ onSliderChange = ([sliderMinSize, sliderMaxSize]) => {
+ this.setState({
+ sliderMinSize,
+ sliderMaxSize
+ });
+
+ this.props.onSizeChange({
+ minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
+ maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1))
+ });
+ }
+
+ onAfterSliderChange = () => {
+ const {
+ minSize,
+ maxSize
+ } = this.props;
+
+ this.setState({
+ sliderMiSize: getSliderValue(minSize, slider.min),
+ sliderMaxSize: getSliderValue(maxSize, slider.max)
+ });
+ }
+
+ onMinSizeChange = ({ value }) => {
+ const minSize = getValue(value);
+
+ this.setState({
+ sliderMinSize: getSliderValue(minSize, slider.min)
+ });
+
+ this.props.onSizeChange({
+ minSize,
+ maxSize: this.props.maxSize
+ });
+ }
+
+ onMaxSizeChange = ({ value }) => {
+ const maxSize = value === MAX ? null : getValue(value);
+
+ this.setState({
+ sliderMaxSize: getSliderValue(maxSize, slider.max)
+ });
+
+ this.props.onSizeChange({
+ minSize: this.props.minSize,
+ maxSize
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ quality,
+ title,
+ minSize,
+ maxSize,
+ advancedSettings,
+ onTitleChange
+ } = this.props;
+
+ const {
+ sliderMinSize,
+ sliderMaxSize
+ } = this.state;
+
+ const minBytes = minSize * 1024 * 1024;
+ const minThirty = formatBytes(minBytes * 90, 2);
+ const minSixty = formatBytes(minBytes * 140, 2);
+
+ const maxBytes = maxSize && maxSize * 1024 * 1024;
+ const maxThirty = maxBytes ? formatBytes(maxBytes * 90, 2) : 'Unlimited';
+ const maxSixty = maxBytes ? formatBytes(maxBytes * 140, 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..a76c9440f
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js
@@ -0,0 +1,64 @@
+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';
+
+const mapDispatchToProps = {
+ setQualityDefinitionValue,
+ clearPendingChanges
+};
+
+class QualityDefinitionConnector extends Component {
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' });
+ }
+
+ //
+ // Listeners
+
+ onTitleChange = ({ value }) => {
+ this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value });
+ }
+
+ onSizeChange = ({ minSize, maxSize }) => {
+ const {
+ id,
+ minSize: currentMinSize,
+ maxSize: currentMaxSize
+ } = this.props;
+
+ if (minSize !== currentMinSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize });
+ }
+
+ if (maxSize !== 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(null, 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..c80769c69
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
@@ -0,0 +1,73 @@
+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,
+ advancedSettings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
Quality
+
Title
+
Size Limit
+
+ {
+ advancedSettings ?
+
+ Megabytes Per Minute
+
:
+ null
+ }
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ Limits are automatically adjusted for the movie runtime.
+
+
+
+
+ );
+ }
+}
+
+QualityDefinitions.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ advancedSettings: PropTypes.bool.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..c2f830afd
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
@@ -0,0 +1,92 @@
+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,
+ (state) => state.settings.advancedSettings,
+ (qualityDefinitions, advancedSettings) => {
+ const items = qualityDefinitions.items.map((item) => {
+ const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {};
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ return {
+ ...qualityDefinitions,
+ items,
+ hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges),
+ advancedSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityDefinitions: fetchQualityDefinitions,
+ dispatchSaveQualityDefinitions: saveQualityDefinitions
+};
+
+class QualityDefinitionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchQualityDefinitions();
+
+ const {
+ dispatchFetchQualityDefinitions,
+ dispatchSaveQualityDefinitions,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchQualityDefinitions();
+ onChildMounted(dispatchSaveQualityDefinitions);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchQualityDefinitions: PropTypes.func.isRequired,
+ dispatchSaveQualityDefinitions: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps, null, { withRef: true })(QualityDefinitionsConnector);
diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js
new file mode 100644
index 000000000..dfd6a24d7
--- /dev/null
+++ b/frontend/src/Settings/Quality/Quality.js
@@ -0,0 +1,68 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector';
+
+class Quality extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Quality;
diff --git a/frontend/src/Settings/Settings.css b/frontend/src/Settings/Settings.css
new file mode 100644
index 000000000..497ef47c0
--- /dev/null
+++ b/frontend/src/Settings/Settings.css
@@ -0,0 +1,18 @@
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ border-bottom: 1px solid #e5e5e5;
+ color: #3a3f51;
+ font-size: 21px;
+
+ &:hover {
+ color: #616573;
+ text-decoration: none;
+ }
+}
+
+.summary {
+ margin-top: 10px;
+ margin-bottom: 30px;
+ color: $dimColor;
+}
diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js
new file mode 100644
index 000000000..db114b6a8
--- /dev/null
+++ b/frontend/src/Settings/Settings.js
@@ -0,0 +1,144 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from './SettingsToolbarConnector';
+import styles from './Settings.css';
+
+function Settings() {
+ return (
+
+
+
+
+
+ Media Management
+
+
+
+ Naming and file management settings
+
+
+
+ Profiles
+
+
+
+ Quality, Language and Delay profiles
+
+
+
+ Quality
+
+
+
+ Quality sizes and naming
+
+
+
+ Indexers
+
+
+
+ Indexers and release restrictions
+
+
+
+ Download Clients
+
+
+
+ Download clients, download handling and remote path mappings
+
+
+
+ Lists
+
+
+
+ Import Lists, list exclusions
+
+
+
+ Connect
+
+
+
+ Notifications, connections to media servers/players and custom scripts
+
+
+
+ Metadata
+
+
+
+ Create metadata files when episodes are imported or series are refreshed
+
+
+
+ Tags
+
+
+
+ See all tags and how they are used. Unused tags can be removed
+
+
+
+ General
+
+
+
+ Port, SSL, username/password, proxy, analytics and updates
+
+
+
+ UI
+
+
+
+ Calendar, date and color impaired options
+
+
+
+ );
+}
+
+Settings.propTypes = {
+};
+
+export default Settings;
diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js
new file mode 100644
index 000000000..8b70857d9
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbar.js
@@ -0,0 +1,105 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PendingChangesModal from './PendingChangesModal';
+import AdvancedSettingsButton from './AdvancedSettingsButton';
+
+class SettingsToolbar extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true });
+ }
+
+ //
+ // Control
+
+ saveSettings = (event) => {
+ event.preventDefault();
+
+ const {
+ hasPendingChanges,
+ onSavePress
+ } = this.props;
+
+ if (hasPendingChanges) {
+ onSavePress();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ showSave,
+ isSaving,
+ hasPendingChanges,
+ hasPendingLocation,
+ additionalButtons,
+ onSavePress,
+ onConfirmNavigation,
+ onCancelNavigation,
+ onAdvancedSettingsPress
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ showSave &&
+
+ }
+
+ {
+ additionalButtons
+ }
+
+
+
+
+ );
+ }
+}
+
+SettingsToolbar.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ showSave: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool,
+ hasPendingLocation: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool,
+ additionalButtons: PropTypes.node,
+ onSavePress: PropTypes.func,
+ onAdvancedSettingsPress: PropTypes.func.isRequired,
+ onConfirmNavigation: PropTypes.func.isRequired,
+ onCancelNavigation: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+SettingsToolbar.defaultProps = {
+ showSave: true
+};
+
+export default keyboardShortcuts(SettingsToolbar);
diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js
new file mode 100644
index 000000000..8bfb3dad5
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbarConnector.js
@@ -0,0 +1,147 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
+import SettingsToolbar from './SettingsToolbar';
+
+function mapStateToProps(state) {
+ return {
+ advancedSettings: state.settings.advancedSettings
+ };
+}
+
+const mapDispatchToProps = {
+ toggleAdvancedSettings
+};
+
+class SettingsToolbarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ };
+
+ this._unblock = null;
+ }
+
+ componentDidMount() {
+ this._unblock = this.props.history.block(this.routerWillLeave);
+ }
+
+ componentWillUnmount() {
+ if (this._unblock) {
+ this._unblock();
+ }
+ }
+
+ //
+ // Control
+
+ routerWillLeave = (nextLocation, nextLocationAction) => {
+ if (this.state.confirmed) {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+
+ return true;
+ }
+
+ if (this.props.hasPendingChanges ) {
+ this.setState({
+ nextLocation,
+ nextLocationAction
+ });
+
+ return false;
+ }
+
+ return true;
+ }
+
+ //
+ // Listeners
+
+ onAdvancedSettingsPress = () => {
+ this.props.toggleAdvancedSettings();
+ }
+
+ onConfirmNavigation = () => {
+ const {
+ nextLocation,
+ nextLocationAction
+ } = this.state;
+
+ const history = this.props.history;
+
+ const path = `${nextLocation.pathname}${nextLocation.search}`;
+
+ this.setState({
+ confirmed: true
+ }, () => {
+ if (nextLocationAction === 'PUSH') {
+ history.push(path);
+ } else {
+ // Unfortunately back and forward both use POP,
+ // which means we don't actually know which direction
+ // the user wanted to go, assuming back.
+
+ history.goBack();
+ }
+ });
+ }
+
+ onCancelNavigation = () => {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const hasPendingLocation = this.state.nextLocation !== null;
+
+ return (
+
+ );
+ }
+}
+
+const historyShape = {
+ block: PropTypes.func.isRequired,
+ goBack: PropTypes.func.isRequired,
+ push: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.propTypes = {
+ hasPendingChanges: PropTypes.bool.isRequired,
+ history: PropTypes.shape(historyShape).isRequired,
+ onSavePress: PropTypes.func,
+ toggleAdvancedSettings: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.defaultProps = {
+ hasPendingChanges: false
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector));
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js
new file mode 100644
index 000000000..ab670359b
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import titleCase from 'Utilities/String/titleCase';
+
+function TagDetailsDelayProfile(props) {
+ const {
+ preferredProtocol,
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay
+ } = props;
+
+ return (
+
+
+ Protocol: {titleCase(preferredProtocol)}
+
+
+
+ {
+ enableUsenet ?
+ `Usenet Delay: ${usenetDelay}` :
+ 'Usenet disabled'
+ }
+
+
+
+ {
+ enableTorrent ?
+ `Torrent Delay: ${torrentDelay}` :
+ 'Torrents disabled'
+ }
+
+
+ );
+}
+
+TagDetailsDelayProfile.propTypes = {
+ preferredProtocol: PropTypes.string.isRequired,
+ enableUsenet: PropTypes.bool.isRequired,
+ enableTorrent: PropTypes.bool.isRequired,
+ usenetDelay: PropTypes.number.isRequired,
+ torrentDelay: PropTypes.number.isRequired
+};
+
+export default TagDetailsDelayProfile;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModal.js b/frontend/src/Settings/Tags/Details/TagDetailsModal.js
new file mode 100644
index 000000000..0fe1ec5d3
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import TagDetailsModalContentConnector from './TagDetailsModalContentConnector';
+
+function TagDetailsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+TagDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TagDetailsModal;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css
new file mode 100644
index 000000000..3488b0509
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css
@@ -0,0 +1,26 @@
+.items {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.item {
+ flex: 0 0 100%;
+}
+
+.restriction {
+ margin-bottom: 5px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-child {
+ margin: 0;
+ padding: 0;
+ border-bottom: none;
+ }
+}
+
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
new file mode 100644
index 000000000..27189c731
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import split from 'Utilities/String/split';
+import { kinds } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Button from 'Components/Link/Button';
+import Label from 'Components/Label';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import TagDetailsDelayProfile from './TagDetailsDelayProfile';
+import styles from './TagDetailsModalContent.css';
+
+function TagDetailsModalContent(props) {
+ const {
+ label,
+ isTagUsed,
+ movies,
+ delayProfiles,
+ notifications,
+ restrictions,
+ onModalClose,
+ onDeleteTagPress
+ } = props;
+
+ return (
+
+
+ Tag Details - {label}
+
+
+
+ {
+ !isTagUsed &&
+ Tag is not used and can be deleted
+ }
+
+ {
+ !!movies.length &&
+
+ {
+ movies.map((item) => {
+ return (
+
+ {item.title}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!delayProfiles.length &&
+
+ {
+ delayProfiles.map((item) => {
+ const {
+ id,
+ preferredProtocol,
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay
+ } = item;
+
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!notifications.length &&
+
+ {
+ notifications.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!restrictions.length &&
+
+ {
+ restrictions.map((item) => {
+ return (
+
+
+ {
+ split(item.required).map((r) => {
+ return (
+
+ {r}
+
+ );
+ })
+ }
+
+
+
+ {
+ split(item.ignored).map((i) => {
+ return (
+
+ {i}
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+
+ }
+
+
+
+ {
+
+ Delete
+
+ }
+
+
+ Close
+
+
+
+ );
+}
+
+TagDetailsModalContent.propTypes = {
+ label: PropTypes.string.isRequired,
+ isTagUsed: PropTypes.bool.isRequired,
+ movies: PropTypes.arrayOf(PropTypes.object).isRequired,
+ delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
+ restrictions: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteTagPress: PropTypes.func.isRequired
+};
+
+export default TagDetailsModalContent;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
new file mode 100644
index 000000000..414fa0ff4
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
@@ -0,0 +1,61 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import TagDetailsModalContent from './TagDetailsModalContent';
+
+function findMatchingItems(ids, items) {
+ return items.filter((s) => {
+ return ids.includes(s.id);
+ });
+}
+
+function createMatchingMovieSelector() {
+ return createSelector(
+ (state, { movieIds }) => movieIds,
+ createAllMoviesSelector(),
+ findMatchingItems
+ );
+}
+
+function createMatchingDelayProfilesSelector() {
+ return createSelector(
+ (state, { delayProfileIds }) => delayProfileIds,
+ (state) => state.settings.delayProfiles.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingNotificationsSelector() {
+ return createSelector(
+ (state, { notificationIds }) => notificationIds,
+ (state) => state.settings.notifications.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingRestrictionsSelector() {
+ return createSelector(
+ (state, { restrictionIds }) => restrictionIds,
+ (state) => state.settings.restrictions.items,
+ findMatchingItems
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createMatchingMovieSelector(),
+ createMatchingDelayProfilesSelector(),
+ createMatchingNotificationsSelector(),
+ createMatchingRestrictionsSelector(),
+ (movies, delayProfiles, notifications, restrictions) => {
+ return {
+ movies,
+ delayProfiles,
+ notifications,
+ restrictions
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(TagDetailsModalContent);
diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css
new file mode 100644
index 000000000..ee425e309
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.css
@@ -0,0 +1,11 @@
+.tag {
+ composes: card from 'Components/Card.css';
+
+ width: 150px;
+}
+
+.label {
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js
new file mode 100644
index 000000000..0d02ef62f
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TagDetailsModal from './Details/TagDetailsModal';
+import styles from './Tag.css';
+
+class Tag extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false,
+ isDeleteTagModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onShowDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ onDeleteTagPress = () => {
+ this.setState({
+ isDetailsModalOpen: false,
+ isDeleteTagModalOpen: true
+ });
+ }
+
+ onDeleteTagModalClose= () => {
+ this.setState({ isDeleteTagModalOpen: false });
+ }
+
+ onConfirmDeleteTag = () => {
+ this.props.onConfirmDeleteTag({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ label,
+ delayProfileIds,
+ notificationIds,
+ restrictionIds,
+ movieIds
+ } = this.props;
+
+ const {
+ isDetailsModalOpen,
+ isDeleteTagModalOpen
+ } = this.state;
+
+ const isTagUsed = !!(
+ delayProfileIds.length ||
+ notificationIds.length ||
+ restrictionIds.length ||
+ movieIds.length
+ );
+
+ return (
+
+
+ {label}
+
+
+ {
+ isTagUsed &&
+
+ {
+ !!movieIds.length &&
+
+ {movieIds.length} movies
+
+ }
+
+ {
+ !!delayProfileIds.length &&
+
+ {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
+
+ }
+
+ {
+ !!notificationIds.length &&
+
+ {notificationIds.length} connection{notificationIds.length > 1 && 's'}
+
+ }
+
+ {
+ !!restrictionIds.length &&
+
+ {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'}
+
+ }
+
+ }
+
+ {
+ !isTagUsed &&
+
+ No links
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Tag.propTypes = {
+ id: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ movieIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onConfirmDeleteTag: PropTypes.func.isRequired
+};
+
+Tag.defaultProps = {
+ delayProfileIds: [],
+ notificationIds: [],
+ restrictionIds: [],
+ movieIds: []
+};
+
+export default Tag;
diff --git a/frontend/src/Settings/Tags/TagConnector.js b/frontend/src/Settings/Tags/TagConnector.js
new file mode 100644
index 000000000..50f610153
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagConnector.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector';
+import { deleteTag } from 'Store/Actions/tagActions';
+import Tag from './Tag';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagDetailsSelector(),
+ (tagDetails) => {
+ return {
+ ...tagDetails
+ };
+ }
+ );
+}
+
+const mapStateToProps = {
+ onConfirmDeleteTag: deleteTag
+};
+
+export default connect(createMapStateToProps, mapStateToProps)(Tag);
diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js
new file mode 100644
index 000000000..56ef92b49
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagSettings.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import TagsConnector from './TagsConnector';
+
+function TagSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default TagSettings;
diff --git a/frontend/src/Settings/Tags/Tags.css b/frontend/src/Settings/Tags/Tags.css
new file mode 100644
index 000000000..5a44f8331
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.css
@@ -0,0 +1,4 @@
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Tags/Tags.js b/frontend/src/Settings/Tags/Tags.js
new file mode 100644
index 000000000..e1375ba76
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import TagConnector from './TagConnector';
+import styles from './Tags.css';
+
+function Tags(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ if (!items.length) {
+ return (
+ No tags have been added yet
+ );
+ }
+
+ return (
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+}
+
+Tags.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Tags;
diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js
new file mode 100644
index 000000000..dccf9b43d
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagsConnector.js
@@ -0,0 +1,72 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchTagDetails } from 'Store/Actions/tagActions';
+import { fetchDelayProfiles, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions';
+import Tags from './Tags';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.tags,
+ (tags) => {
+ const isFetching = tags.isFetching || tags.details.isFetching;
+ const error = tags.error || tags.details.error;
+ const isPopulated = tags.isPopulated && tags.details.isPopulated;
+
+ return {
+ ...tags,
+ isFetching,
+ error,
+ isPopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchTagDetails: fetchTagDetails,
+ dispatchFetchDelayProfiles: fetchDelayProfiles,
+ dispatchFetchNotifications: fetchNotifications,
+ dispatchFetchRestrictions: fetchRestrictions
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchTagDetails,
+ dispatchFetchDelayProfiles,
+ dispatchFetchNotifications,
+ dispatchFetchRestrictions
+ } = this.props;
+
+ dispatchFetchTagDetails();
+ dispatchFetchDelayProfiles();
+ dispatchFetchNotifications();
+ dispatchFetchRestrictions();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ dispatchFetchTagDetails: PropTypes.func.isRequired,
+ dispatchFetchDelayProfiles: PropTypes.func.isRequired,
+ dispatchFetchNotifications: PropTypes.func.isRequired,
+ dispatchFetchRestrictions: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js
new file mode 100644
index 000000000..71356a5f0
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.js
@@ -0,0 +1,195 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+export const firstDayOfWeekOptions = [
+ { key: 0, value: 'Sunday' },
+ { key: 1, value: 'Monday' }
+];
+
+export const weekColumnOptions = [
+ { key: 'ddd M/D', value: 'Tue 3/25' },
+ { key: 'ddd MM/DD', value: 'Tue 03/25' },
+ { key: 'ddd D/M', value: 'Tue 25/03' },
+ { key: 'ddd DD/MM', value: 'Tue 25/03' }
+];
+
+const shortDateFormatOptions = [
+ { key: 'MMM D YYYY', value: 'Mar 25 2014' },
+ { key: 'DD MMM YYYY', value: '25 Mar 2014' },
+ { key: 'MM/D/YYYY', value: '03/25/2014' },
+ { key: 'MM/DD/YYYY', value: '03/25/2014' },
+ { key: 'DD/MM/YYYY', value: '25/03/2014' },
+ { key: 'YYYY-MM-DD', value: '2014-03-25' }
+];
+
+const longDateFormatOptions = [
+ { key: 'dddd, MMMM D YYYY', value: 'Tuesday, March 25, 2014' },
+ { key: 'dddd, D MMMM YYYY', value: 'Tuesday, 25 March, 2014' }
+];
+
+export const timeFormatOptions = [
+ { key: 'h(:mm)a', value: '5pm/5:30pm' },
+ { key: 'HH:mm', value: '17:00/17:30' }
+];
+
+class UISettings extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load UI settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+ }
+
+}
+
+UISettings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default UISettings;
diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js
new file mode 100644
index 000000000..ac8c9f8f1
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettingsConnector.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { setUISettingsValue, saveUISettings, fetchUISettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import UISettings from './UISettings';
+
+const SECTION = 'ui';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setUISettingsValue,
+ saveUISettings,
+ fetchUISettings,
+ clearPendingChanges
+};
+
+class UISettingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUISettings();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: `settings.${SECTION}` });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setUISettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveUISettings();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UISettingsConnector.propTypes = {
+ setUISettingsValue: PropTypes.func.isRequired,
+ saveUISettings: PropTypes.func.isRequired,
+ fetchUISettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector);
diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js
new file mode 100644
index 000000000..aadc9cec7
--- /dev/null
+++ b/frontend/src/Shared/piwikCheck.js
@@ -0,0 +1,11 @@
+if (window.Radarr.analytics) {
+ const d = document;
+ const g = d.createElement('script');
+ const s = d.getElementsByTagName('script')[0];
+
+ g.type = 'text/javascript';
+ g.async = true;
+ g.defer = true;
+ g.src = '//piwik.sonarr.tv/piwik.js';
+ s.parentNode.insertBefore(g, s);
+}
diff --git a/frontend/src/Shims/jquery.js b/frontend/src/Shims/jquery.js
new file mode 100644
index 000000000..d0234889c
--- /dev/null
+++ b/frontend/src/Shims/jquery.js
@@ -0,0 +1,10 @@
+import $ from 'jquery';
+import ajax from 'jQuery/jquery.ajax';
+
+ajax($);
+
+const jquery = $;
+window.$ = $;
+window.jQuery = $;
+
+export default jquery;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js
new file mode 100644
index 000000000..2952973a9
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js
@@ -0,0 +1,12 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createClearReducer(section, defaultState) {
+ return (state) => {
+ const newState = Object.assign(getSectionState(state, section), defaultState);
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createClearReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js
new file mode 100644
index 000000000..d58bb1cd4
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js
@@ -0,0 +1,14 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetClientSideCollectionFilterReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ newState.selectedFilterKey = payload.selectedFilterKey;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionFilterReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js
new file mode 100644
index 000000000..1bc048a80
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js
@@ -0,0 +1,29 @@
+import { sortDirections } from 'Helpers/Props';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetClientSideCollectionSortReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ const sortKey = payload.sortKey || newState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === newState.sortKey) {
+ sortDirection = newState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = newState.sortDirection;
+ }
+ }
+
+ newState.sortKey = sortKey;
+ newState.sortDirection = sortDirection;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionSortReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js
new file mode 100644
index 000000000..3af58dd3b
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js
@@ -0,0 +1,23 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetProviderFieldValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+ const fields = Object.assign({}, newState.pendingChanges.fields || {});
+
+ fields[name] = value;
+
+ newState.pendingChanges.fields = fields;
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetProviderFieldValueReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js
new file mode 100644
index 000000000..474eb7bb2
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetSettingValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+
+ const currentValue = newState.item ? newState.item[name] : null;
+ const pendingState = newState.pendingChanges;
+
+ let parsedValue = null;
+
+ if (_.isNumber(currentValue) && value != null) {
+ parsedValue = parseInt(value);
+ } else {
+ parsedValue = value;
+ }
+
+ if (currentValue === parsedValue) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = parsedValue;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetSettingValueReducer;
diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js
new file mode 100644
index 000000000..70b57446d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const whitelistedProperties = [
+ 'pageSize',
+ 'columns',
+ 'tableOptions'
+];
+
+function createSetTableOptionReducer(section) {
+ return (state, { payload }) => {
+ const newState = Object.assign(
+ getSectionState(state, section),
+ _.pick(payload, whitelistedProperties));
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetTableOptionReducer;
diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
new file mode 100644
index 000000000..87be89828
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
@@ -0,0 +1,42 @@
+import $ from 'jquery';
+import updateEpisodes from 'Utilities/Episode/updateEpisodes';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const {
+ episodeIds,
+ monitored
+ } = payload;
+
+ const state = getSectionState(getState(), section, true);
+
+ dispatch(updateEpisodes(section, state.items, episodeIds, {
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/episode/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ episodeIds, monitored }),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ dispatch(updateEpisodes(section, state.items, episodeIds, {
+ isSaving: false,
+ monitored
+ }));
+
+ dispatch(fetchHandler());
+ });
+
+ promise.fail(() => {
+ dispatch(updateEpisodes(section, state.items, episodeIds, {
+ isSaving: false
+ }));
+ });
+ };
+}
+
+export default createBatchToggleEpisodeMonitoredHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js
new file mode 100644
index 000000000..c9cd058bd
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js
@@ -0,0 +1,44 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, update, updateItem } from '../baseActions';
+
+export default function createFetchHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: id == null ? url : `${url}/${id}`,
+ data: otherPayload,
+ traditional: true
+ });
+
+ request.done((data) => {
+ dispatch(batchActions([
+ id == null ? update({ section, data }) : updateItem({ section, ...data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr.aborted ? null : xhr
+ }));
+ });
+
+ return abortRequest;
+ };
+}
diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
new file mode 100644
index 000000000..2c02c8c20
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
@@ -0,0 +1,33 @@
+import $ from 'jquery';
+import { set } from '../baseActions';
+
+function createFetchSchemaHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSchemaFetching: true }));
+
+ const promise = $.ajax({
+ url
+ });
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isSchemaFetching: false,
+ isSchemaPopulated: true,
+ schemaError: null,
+ schema: data
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSchemaFetching: false,
+ isSchemaPopulated: true,
+ schemaError: xhr
+ }));
+ });
+ };
+}
+
+export default createFetchSchemaHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
new file mode 100644
index 000000000..e7f1d4b04
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
@@ -0,0 +1,67 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
+import getSectionState from 'Utilities/State/getSectionState';
+import { set, updateServerSideCollection } from '../baseActions';
+
+function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const sectionState = getSectionState(getState(), section, true);
+ const page = payload.page || sectionState.page || 1;
+
+ const data = Object.assign({ page },
+ _.pick(sectionState, [
+ 'pageSize',
+ 'sortDirection',
+ 'sortKey'
+ ]));
+
+ if (fetchDataAugmenter) {
+ fetchDataAugmenter(getState, payload, data);
+ }
+
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters
+ } = sectionState;
+
+ const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
+
+ selectedFilters.forEach((filter) => {
+ data[filter.key] = filter.value;
+ });
+
+ const promise = $.ajax({
+ url,
+ data
+ });
+
+ promise.done((response) => {
+ dispatch(batchActions([
+ updateServerSideCollection({ section, data: response }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+}
+
+export default createFetchServerSideCollectionHandler;
diff --git a/frontend/src/Store/Actions/Creators/createHandleActions.js b/frontend/src/Store/Actions/Creators/createHandleActions.js
new file mode 100644
index 000000000..c3315ce94
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createHandleActions.js
@@ -0,0 +1,143 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import {
+ SET,
+ UPDATE,
+ UPDATE_ITEM,
+ UPDATE_SERVER_SIDE_COLLECTION,
+ CLEAR_PENDING_CHANGES,
+ REMOVE_ITEM
+} from 'Store/Actions/baseActions';
+
+const blacklistedProperties = [
+ 'section',
+ 'id'
+];
+
+export default function createHandleActions(handlers, defaultState, section) {
+ return handleActions({
+
+ [SET]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = Object.assign(getSectionState(state, payloadSection),
+ _.omit(payload, blacklistedProperties));
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+
+ if (_.isArray(payload.data)) {
+ newState.items = payload.data;
+ } else {
+ newState.item = payload.data;
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE_ITEM]: function(state, { payload }) {
+ const {
+ section: payloadSection,
+ updateOnly = false,
+ ...otherProps
+ } = payload;
+
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+ const items = newState.items;
+ const index = _.findIndex(items, { id: payload.id });
+
+ newState.items = [...items];
+
+ // TODO: Move adding to it's own reducer
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...otherProps });
+ } else if (!updateOnly) {
+ newState.items.push({ ...otherProps });
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [CLEAR_PENDING_CHANGES]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+ newState.pendingChanges = {};
+
+ if (newState.hasOwnProperty('saveError')) {
+ newState.saveError = null;
+ }
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [REMOVE_ITEM]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const newState = getSectionState(state, payloadSection);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ return updateSectionState(state, payloadSection, newState);
+ }
+
+ return state;
+ },
+
+ [UPDATE_SERVER_SIDE_COLLECTION]: function(state, { payload }) {
+ const payloadSection = payload.section;
+ const [baseSection] = payloadSection.split('.');
+
+ if (section === baseSection) {
+ const data = payload.data;
+ const newState = getSectionState(state, payloadSection);
+
+ const serverState = _.omit(data, ['records']);
+ const calculatedState = {
+ totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1),
+ items: data.records
+ };
+
+ return updateSectionState(state, payloadSection, Object.assign(newState, serverState, calculatedState));
+ }
+
+ return state;
+ },
+
+ ...handlers
+
+ }, defaultState);
+}
diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
new file mode 100644
index 000000000..6f353ed17
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
@@ -0,0 +1,45 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, removeItem } from '../baseActions';
+
+function createRemoveItemHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ const {
+ id,
+ ...queryParams
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const ajaxOptions = {
+ url: `${url}/${id}?${$.param(queryParams, true)}`,
+ method: 'DELETE'
+ };
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }),
+
+ removeItem({ section, id })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+
+ return promise;
+ };
+}
+
+export default createRemoveItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js
new file mode 100644
index 000000000..e63c9f993
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js
@@ -0,0 +1,43 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import { set, update } from '../baseActions';
+
+function createSaveHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSaving: true }));
+
+ const state = getSectionState(getState(), section, true);
+ const saveData = Object.assign({}, state.item, state.pendingChanges, payload);
+
+ const promise = $.ajax({
+ url,
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+}
+
+export default createSaveHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
new file mode 100644
index 000000000..6d555bf23
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
@@ -0,0 +1,70 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set, updateItem } from '../baseActions';
+
+const abortCurrentRequests = {};
+
+export function createCancelSaveProviderHandler(section) {
+ return function(getState, payload, dispatch) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+}
+
+function createSaveProviderHandler(section, url, options = {}) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSaving: true }));
+
+ const {
+ id,
+ queryParams = {},
+ ...otherPayload
+ } = payload;
+
+ const saveData = getProviderState({ id, ...otherPayload }, getState, section);
+
+ const ajaxOptions = {
+ url: `${url}?${$.param(queryParams, true)}`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ };
+
+ if (id) {
+ ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`;
+ ajaxOptions.method = 'PUT';
+ }
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortCurrentRequests[section] = abortRequest;
+
+ request.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section, ...data }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr.aborted ? null : xhr
+ }));
+ });
+ };
+}
+
+export default createSaveProviderHandler;
diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js
new file mode 100644
index 000000000..f81723769
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js
@@ -0,0 +1,52 @@
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import pages from 'Utilities/pages';
+import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler';
+import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler';
+import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler';
+import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler';
+
+function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) {
+ const actionHandlers = {};
+ const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH];
+ const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter);
+ actionHandlers[fetchHandlerType] = fetchHandler;
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) {
+ const handlerType = handlers[serverSideCollectionHandlers.SORT];
+ actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, fetchThunk);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FILTER];
+ actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, fetchThunk);
+ }
+
+ return actionHandlers;
+}
+
+export default createServerSideCollectionHandlers;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
new file mode 100644
index 000000000..d7e476444
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
@@ -0,0 +1,10 @@
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionFilterHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, ...payload }));
+ dispatch(fetchHandler({ page: 1 }));
+ };
+}
+
+export default createSetServerSideCollectionFilterHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
new file mode 100644
index 000000000..12b21bb0d
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
@@ -0,0 +1,35 @@
+import pages from 'Utilities/pages';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createSetServerSideCollectionPageHandler(section, page, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const sectionState = getSectionState(getState(), section, true);
+ const currentPage = sectionState.page || 1;
+ let nextPage = 0;
+
+ switch (page) {
+ case pages.FIRST:
+ nextPage = 1;
+ break;
+ case pages.PREVIOUS:
+ nextPage = currentPage - 1;
+ break;
+ case pages.NEXT:
+ nextPage = currentPage + 1;
+ break;
+ case pages.LAST:
+ nextPage = sectionState.totalPages;
+ break;
+ default:
+ nextPage = payload.page;
+ }
+
+ // If we prefer to update the page immediately we should
+ // set the page and not pass a page to the fetch handler.
+
+ // dispatch(set({ section, page: nextPage }));
+ dispatch(fetchHandler({ page: nextPage }));
+ };
+}
+
+export default createSetServerSideCollectionPageHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
new file mode 100644
index 000000000..fbd66e83e
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
@@ -0,0 +1,26 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import { sortDirections } from 'Helpers/Props';
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionSortHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const sectionState = getSectionState(getState(), section, true);
+ const sortKey = payload.sortKey || sectionState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === sectionState.sortKey) {
+ sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = sectionState.sortDirection;
+ }
+ }
+
+ dispatch(set({ section, sortKey, sortDirection }));
+ dispatch(fetchHandler());
+ };
+}
+
+export default createSetServerSideCollectionSortHandler;
diff --git a/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js
new file mode 100644
index 000000000..77deaec64
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js
@@ -0,0 +1,34 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set } from '../baseActions';
+
+function createTestAllProvidersHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isTestingAll: true }));
+
+ const ajaxOptions = {
+ url: `${url}/testall`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json'
+ };
+
+ const { request } = createAjaxRequest(ajaxOptions);
+
+ request.done((data) => {
+ dispatch(set({
+ section,
+ isTestingAll: false,
+ saveError: null
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isTestingAll: false
+ }));
+ });
+ };
+}
+
+export default createTestAllProvidersHandler;
diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
new file mode 100644
index 000000000..ca26883fb
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
@@ -0,0 +1,52 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set } from '../baseActions';
+
+const abortCurrentRequests = {};
+
+export function createCancelTestProviderHandler(section) {
+ return function(getState, payload, dispatch) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+}
+
+function createTestProviderHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isTesting: true }));
+
+ const testData = getProviderState(payload, getState, section);
+
+ const ajaxOptions = {
+ url: `${url}/test`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(testData)
+ };
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortCurrentRequests[section] = abortRequest;
+
+ request.done((data) => {
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: null
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isTesting: false,
+ saveError: xhr.aborted ? null : xhr
+ }));
+ });
+ };
+}
+
+export default createTestProviderHandler;
diff --git a/frontend/src/Store/Actions/Settings/delayProfiles.js b/frontend/src/Store/Actions/Settings/delayProfiles.js
new file mode 100644
index 000000000..68232e510
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/delayProfiles.js
@@ -0,0 +1,103 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+import { update } from 'Store/Actions/baseActions';
+
+//
+// Variables
+
+const section = 'settings.delayProfiles';
+
+//
+// Actions Types
+
+export const FETCH_DELAY_PROFILES = 'settings/delayProfiles/fetchDelayProfiles';
+export const FETCH_DELAY_PROFILE_SCHEMA = 'settings/delayProfiles/fetchDelayProfileSchema';
+export const SAVE_DELAY_PROFILE = 'settings/delayProfiles/saveDelayProfile';
+export const DELETE_DELAY_PROFILE = 'settings/delayProfiles/deleteDelayProfile';
+export const REORDER_DELAY_PROFILE = 'settings/delayProfiles/reorderDelayProfile';
+export const SET_DELAY_PROFILE_VALUE = 'settings/delayProfiles/setDelayProfileValue';
+
+//
+// Action Creators
+
+export const fetchDelayProfiles = createThunk(FETCH_DELAY_PROFILES);
+export const fetchDelayProfileSchema = createThunk(FETCH_DELAY_PROFILE_SCHEMA);
+export const saveDelayProfile = createThunk(SAVE_DELAY_PROFILE);
+export const deleteDelayProfile = createThunk(DELETE_DELAY_PROFILE);
+export const reorderDelayProfile = createThunk(REORDER_DELAY_PROFILE);
+
+export const setDelayProfileValue = createAction(SET_DELAY_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DELAY_PROFILES]: createFetchHandler(section, '/delayprofile'),
+ [FETCH_DELAY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/delayprofile/schema'),
+
+ [SAVE_DELAY_PROFILE]: createSaveProviderHandler(section, '/delayprofile'),
+ [DELETE_DELAY_PROFILE]: createRemoveItemHandler(section, '/delayprofile'),
+
+ [REORDER_DELAY_PROFILE]: (getState, payload, dispatch) => {
+ const { id, moveIndex } = payload;
+ const moveOrder = moveIndex + 1;
+ const delayProfiles = getState().settings.delayProfiles.items;
+ const moving = _.find(delayProfiles, { id });
+
+ // Don't move if the order hasn't changed
+ if (moving.order === moveOrder) {
+ return;
+ }
+
+ const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null;
+ const afterQueryParam = after ? `after=${after.id}` : '';
+
+ const promise = $.ajax({
+ method: 'PUT',
+ url: `/delayprofile/reorder/${id}?${afterQueryParam}`
+ });
+
+ promise.done((data) => {
+ dispatch(update({ section, data }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/downloadClientOptions.js b/frontend/src/Store/Actions/Settings/downloadClientOptions.js
new file mode 100644
index 000000000..6d4a3954d
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/downloadClientOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.downloadClientOptions';
+
+//
+// Actions Types
+
+export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS';
+export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE';
+export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS';
+
+//
+// Action Creators
+
+export const fetchDownloadClientOptions = createThunk(FETCH_DOWNLOAD_CLIENT_OPTIONS);
+export const saveDownloadClientOptions = createThunk(SAVE_DOWNLOAD_CLIENT_OPTIONS);
+export const setDownloadClientOptionsValue = createAction(SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler(section, '/config/downloadclient'),
+ [SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler(section, '/config/downloadclient')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js
new file mode 100644
index 000000000..a268053f7
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/downloadClients.js
@@ -0,0 +1,117 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.downloadClients';
+
+//
+// Actions Types
+
+export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients';
+export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema';
+export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema';
+export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue';
+export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue';
+export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient';
+export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient';
+export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient';
+export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
+export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
+export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
+
+//
+// Action Creators
+
+export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS);
+export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA);
+export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA);
+
+export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT);
+export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT);
+export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
+export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
+export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
+export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
+
+export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'),
+ [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'),
+
+ [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'),
+ [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section),
+ [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
+ [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
+ [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
+ [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section),
+ [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.enable = true;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js
new file mode 100644
index 000000000..f5e8c277e
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/general.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.general';
+
+//
+// Actions Types
+
+export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings';
+export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue';
+export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings';
+
+//
+// Action Creators
+
+export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS);
+export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS);
+export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'),
+ [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js
new file mode 100644
index 000000000..53fb21651
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexerOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.indexerOptions';
+
+//
+// Actions Types
+
+export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions';
+export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions';
+export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue';
+
+//
+// Action Creators
+
+export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS);
+export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS);
+export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'),
+ [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js
new file mode 100644
index 000000000..5c1b0a508
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexers.js
@@ -0,0 +1,118 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.indexers';
+
+//
+// Actions Types
+
+export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers';
+export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema';
+export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema';
+export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue';
+export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue';
+export const SAVE_INDEXER = 'settings/indexers/saveIndexer';
+export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer';
+export const DELETE_INDEXER = 'settings/indexers/deleteIndexer';
+export const TEST_INDEXER = 'settings/indexers/testIndexer';
+export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
+export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
+
+//
+// Action Creators
+
+export const fetchIndexers = createThunk(FETCH_INDEXERS);
+export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA);
+export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA);
+
+export const saveIndexer = createThunk(SAVE_INDEXER);
+export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER);
+export const deleteIndexer = createThunk(DELETE_INDEXER);
+export const testIndexer = createThunk(TEST_INDEXER);
+export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
+export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
+
+export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'),
+ [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'),
+
+ [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'),
+ [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section),
+ [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
+ [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
+ [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
+ [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_VALUE]: createSetSettingValueReducer(section),
+ [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_INDEXER_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.enableRss = selectedSchema.supportsRss;
+ selectedSchema.enableSearch = selectedSchema.supportsSearch;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/mediaManagement.js b/frontend/src/Store/Actions/Settings/mediaManagement.js
new file mode 100644
index 000000000..4ae9eba0c
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/mediaManagement.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.mediaManagement';
+
+//
+// Actions Types
+
+export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/fetchMediaManagementSettings';
+export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/saveMediaManagementSettings';
+export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'settings/mediaManagement/setMediaManagementSettingsValue';
+
+//
+// Action Creators
+
+export const fetchMediaManagementSettings = createThunk(FETCH_MEDIA_MANAGEMENT_SETTINGS);
+export const saveMediaManagementSettings = createThunk(SAVE_MEDIA_MANAGEMENT_SETTINGS);
+export const setMediaManagementSettingsValue = createAction(SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler(section, '/config/mediamanagement'),
+ [SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler(section, '/config/mediamanagement')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/metadata.js b/frontend/src/Store/Actions/Settings/metadata.js
new file mode 100644
index 000000000..ed5e0aa86
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/metadata.js
@@ -0,0 +1,75 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+
+//
+// Variables
+
+const section = 'settings.metadata';
+
+//
+// Actions Types
+
+export const FETCH_METADATA = 'settings/metadata/fetchMetadata';
+export const SET_METADATA_VALUE = 'settings/metadata/setMetadataValue';
+export const SET_METADATA_FIELD_VALUE = 'settings/metadata/setMetadataFieldValue';
+export const SAVE_METADATA = 'settings/metadata/saveMetadata';
+
+//
+// Action Creators
+
+export const fetchMetadata = createThunk(FETCH_METADATA);
+export const saveMetadata = createThunk(SAVE_METADATA);
+
+export const setMetadataValue = createAction(SET_METADATA_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setMetadataFieldValue = createAction(SET_METADATA_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_METADATA]: createFetchHandler(section, '/metadata'),
+ [SAVE_METADATA]: createSaveProviderHandler(section, '/metadata')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_METADATA_VALUE]: createSetSettingValueReducer(section),
+ [SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/naming.js b/frontend/src/Store/Actions/Settings/naming.js
new file mode 100644
index 000000000..27add8309
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/naming.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.naming';
+
+//
+// Actions Types
+
+export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings';
+export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings';
+export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue';
+
+//
+// Action Creators
+
+export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS);
+export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS);
+export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'),
+ [SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/namingExamples.js b/frontend/src/Store/Actions/Settings/namingExamples.js
new file mode 100644
index 000000000..e3f2ae01c
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/namingExamples.js
@@ -0,0 +1,79 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk } from 'Store/thunks';
+import { set, update } from 'Store/Actions/baseActions';
+
+//
+// Variables
+
+const section = 'settings.namingExamples';
+
+//
+// Actions Types
+
+export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples';
+
+//
+// Action Creators
+
+export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const naming = getState().settings.naming;
+
+ const promise = $.ajax({
+ url: '/config/naming/examples',
+ data: Object.assign({}, naming.item, naming.pendingChanges)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {}
+
+};
diff --git a/frontend/src/Store/Actions/Settings/netImportOptions.js b/frontend/src/Store/Actions/Settings/netImportOptions.js
new file mode 100644
index 000000000..06a6a13d9
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/netImportOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.netImportOptions';
+
+//
+// Actions Types
+
+export const FETCH_NET_IMPORT_OPTIONS = 'settings/netImportOptions/fetchNetImportOptions';
+export const SAVE_NET_IMPORT_OPTIONS = 'settings/netImportOptions/saveNetImportOptions';
+export const SET_NET_IMPORT_OPTIONS_VALUE = 'settings/netImportOptions/setNetImportOptionsValue';
+
+//
+// Action Creators
+
+export const fetchNetImportOptions = createThunk(FETCH_NET_IMPORT_OPTIONS);
+export const saveNetImportOptions = createThunk(SAVE_NET_IMPORT_OPTIONS);
+export const setNetImportOptionsValue = createAction(SET_NET_IMPORT_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NET_IMPORT_OPTIONS]: createFetchHandler(section, '/config/netimport'),
+ [SAVE_NET_IMPORT_OPTIONS]: createSaveHandler(section, '/config/netimport')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NET_IMPORT_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/netImports.js b/frontend/src/Store/Actions/Settings/netImports.js
new file mode 100644
index 000000000..37125af2b
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/netImports.js
@@ -0,0 +1,116 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.netImports';
+
+//
+// Actions Types
+
+export const FETCH_NET_IMPORTS = 'settings/netImports/fetchNetImports';
+export const FETCH_NET_IMPORT_SCHEMA = 'settings/netImports/fetchNetImportSchema';
+export const SELECT_NET_IMPORT_SCHEMA = 'settings/netImports/selectNetImportSchema';
+export const SET_NET_IMPORT_VALUE = 'settings/netImports/setNetImportValue';
+export const SET_NET_IMPORT_FIELD_VALUE = 'settings/netImports/setNetImportFieldValue';
+export const SAVE_NET_IMPORT = 'settings/netImports/saveNetImport';
+export const CANCEL_SAVE_NET_IMPORT = 'settings/netImports/cancelSaveNetImport';
+export const DELETE_NET_IMPORT = 'settings/netImports/deleteNetImport';
+export const TEST_NET_IMPORT = 'settings/netImports/testNetImport';
+export const CANCEL_TEST_NET_IMPORT = 'settings/netImports/cancelTestNetImport';
+export const TEST_ALL_NET_IMPORT = 'settings/netImports/testAllNetImport';
+
+//
+// Action Creators
+
+export const fetchNetImports = createThunk(FETCH_NET_IMPORTS);
+export const fetchNetImportSchema = createThunk(FETCH_NET_IMPORT_SCHEMA);
+export const selectNetImportSchema = createAction(SELECT_NET_IMPORT_SCHEMA);
+
+export const saveNetImport = createThunk(SAVE_NET_IMPORT);
+export const cancelSaveNetImport = createThunk(CANCEL_SAVE_NET_IMPORT);
+export const deleteNetImport = createThunk(DELETE_NET_IMPORT);
+export const testNetImport = createThunk(TEST_NET_IMPORT);
+export const cancelTestNetImport = createThunk(CANCEL_TEST_NET_IMPORT);
+export const testAllNetImport = createThunk(TEST_ALL_NET_IMPORT);
+
+export const setNetImportValue = createAction(SET_NET_IMPORT_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setNetImportFieldValue = createAction(SET_NET_IMPORT_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NET_IMPORTS]: createFetchHandler(section, '/netimport'),
+ [FETCH_NET_IMPORT_SCHEMA]: createFetchSchemaHandler(section, '/netimport/schema'),
+
+ [SAVE_NET_IMPORT]: createSaveProviderHandler(section, '/netimport'),
+ [CANCEL_SAVE_NET_IMPORT]: createCancelSaveProviderHandler(section),
+ [DELETE_NET_IMPORT]: createRemoveItemHandler(section, '/netimport'),
+ [TEST_NET_IMPORT]: createTestProviderHandler(section, '/netimport'),
+ [CANCEL_TEST_NET_IMPORT]: createCancelTestProviderHandler(section),
+ [TEST_ALL_NET_IMPORT]: createTestAllProvidersHandler(section, '/netimport')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NET_IMPORT_VALUE]: createSetSettingValueReducer(section),
+ [SET_NET_IMPORT_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_NET_IMPORT_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js
new file mode 100644
index 000000000..b2c28dac9
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/notifications.js
@@ -0,0 +1,115 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.notifications';
+
+//
+// Actions Types
+
+export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications';
+export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema';
+export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema';
+export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue';
+export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue';
+export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification';
+export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification';
+export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification';
+export const TEST_NOTIFICATION = 'settings/notifications/testNotification';
+export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification';
+
+//
+// Action Creators
+
+export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS);
+export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA);
+export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA);
+
+export const saveNotification = createThunk(SAVE_NOTIFICATION);
+export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION);
+export const deleteNotification = createThunk(DELETE_NOTIFICATION);
+export const testNotification = createThunk(TEST_NOTIFICATION);
+export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION);
+
+export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'),
+ [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'),
+
+ [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'),
+ [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section),
+ [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'),
+ [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'),
+ [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section)
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section),
+ [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.onGrab = selectedSchema.supportsOnGrab;
+ selectedSchema.onDownload = selectedSchema.supportsOnDownload;
+ selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
+ selectedSchema.onRename = selectedSchema.supportsOnRename;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js
new file mode 100644
index 000000000..fb6572f45
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js
@@ -0,0 +1,135 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk } from 'Store/thunks';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+import { clearPendingChanges, set, update } from 'Store/Actions/baseActions';
+
+//
+// Variables
+
+const section = 'settings.qualityDefinitions';
+
+//
+// Actions Types
+
+export const FETCH_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/fetchQualityDefinitions';
+export const SAVE_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/saveQualityDefinitions';
+export const SET_QUALITY_DEFINITION_VALUE = 'settings/qualityDefinitions/setQualityDefinitionValue';
+
+//
+// Action Creators
+
+export const fetchQualityDefinitions = createThunk(FETCH_QUALITY_DEFINITIONS);
+export const saveQualityDefinitions = createThunk(SAVE_QUALITY_DEFINITIONS);
+
+export const setQualityDefinitionValue = createAction(SET_QUALITY_DEFINITION_VALUE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_QUALITY_DEFINITIONS]: createFetchHandler(section, '/qualitydefinition'),
+ [SAVE_QUALITY_DEFINITIONS]: createSaveHandler(section, '/qualitydefinition'),
+
+ [SAVE_QUALITY_DEFINITIONS]: function(getState, payload, dispatch) {
+ const qualityDefinitions = getState().settings.qualityDefinitions;
+
+ const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => {
+ const id = parseInt(key);
+ const pendingChanges = qualityDefinitions.pendingChanges[id] || {};
+ const item = _.find(qualityDefinitions.items, { id });
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ // If there is nothing to save don't bother isSaving
+ if (!upatedDefinitions || !upatedDefinitions.length) {
+ return;
+ }
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ method: 'PUT',
+ url: '/qualityDefinition/update',
+ data: JSON.stringify(upatedDefinitions)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ }),
+
+ update({ section, data }),
+ clearPendingChanges({ section })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) {
+ const { id, name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = _.cloneDeep(newState.pendingChanges);
+
+ const pendingState = newState.pendingChanges[id] || {};
+ const currentValue = _.find(newState.items, { id })[name];
+
+ if (currentValue === value) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = value;
+ }
+
+ if (_.isEmpty(pendingState)) {
+ delete newState.pendingChanges[id];
+ } else {
+ newState.pendingChanges[id] = pendingState;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js
new file mode 100644
index 000000000..6fdc204a0
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js
@@ -0,0 +1,97 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.qualityProfiles';
+
+//
+// Actions Types
+
+export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles';
+export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema';
+export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile';
+export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile';
+export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue';
+export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile';
+
+//
+// Action Creators
+
+export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES);
+export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA);
+export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE);
+export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE);
+
+export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'),
+ [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'),
+ [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'),
+ [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_QUALITY_PROFILE]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js
new file mode 100644
index 000000000..3cfcc7f1f
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js
@@ -0,0 +1,69 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.remotePathMappings';
+
+//
+// Actions Types
+
+export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings';
+export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping';
+export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping';
+export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue';
+
+//
+// Action Creators
+
+export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS);
+export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING);
+export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING);
+
+export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'),
+ [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'),
+ [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/restrictions.js b/frontend/src/Store/Actions/Settings/restrictions.js
new file mode 100644
index 000000000..190b5124e
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/restrictions.js
@@ -0,0 +1,71 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.restrictions';
+
+//
+// Actions Types
+
+export const FETCH_RESTRICTIONS = 'settings/restrictions/fetchRestrictions';
+export const SAVE_RESTRICTION = 'settings/restrictions/saveRestriction';
+export const DELETE_RESTRICTION = 'settings/restrictions/deleteRestriction';
+export const SET_RESTRICTION_VALUE = 'settings/restrictions/setRestrictionValue';
+
+//
+// Action Creators
+
+export const fetchRestrictions = createThunk(FETCH_RESTRICTIONS);
+export const saveRestriction = createThunk(SAVE_RESTRICTION);
+export const deleteRestriction = createThunk(DELETE_RESTRICTION);
+
+export const setRestrictionValue = createAction(SET_RESTRICTION_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_RESTRICTIONS]: createFetchHandler(section, '/restriction'),
+
+ [SAVE_RESTRICTION]: createSaveProviderHandler(section, '/restriction'),
+
+ [DELETE_RESTRICTION]: createRemoveItemHandler(section, '/restriction')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_RESTRICTION_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js
new file mode 100644
index 000000000..97d7223fd
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/ui.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.ui';
+
+//
+// Actions Types
+
+export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings';
+export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE';
+export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS';
+
+//
+// Action Creators
+
+export const fetchUISettings = createThunk(FETCH_UI_SETTINGS);
+export const saveUISettings = createThunk(SAVE_UI_SETTINGS);
+export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'),
+ [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js
new file mode 100644
index 000000000..a0c784a7d
--- /dev/null
+++ b/frontend/src/Store/Actions/actionTypes.js
@@ -0,0 +1,21 @@
+//
+// App
+
+export const SHOW_MESSAGE = 'SHOW_MESSAGE';
+export const HIDE_MESSAGE = 'HIDE_MESSAGE';
+export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS';
+export const SET_VERSION = 'SET_VERSION';
+export const SET_APP_VALUE = 'SET_APP_VALUE';
+export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE';
+
+//
+// Settings
+
+export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings';
+export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue';
+export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings';
+
+//
+// Languages
+
+export const FETCH_LANGUAGES = 'FETCH_LANGUAGES';
diff --git a/frontend/src/Store/Actions/addMovieActions.js b/frontend/src/Store/Actions/addMovieActions.js
new file mode 100644
index 000000000..6232c31aa
--- /dev/null
+++ b/frontend/src/Store/Actions/addMovieActions.js
@@ -0,0 +1,177 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getNewMovie from 'Utilities/Movie/getNewMovie';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'addMovie';
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isAdding: false,
+ isAdded: false,
+ addError: null,
+ items: [],
+
+ defaults: {
+ rootFolderPath: '',
+ monitor: 'true',
+ qualityProfileId: 0,
+ tags: []
+ }
+};
+
+export const persistState = [
+ 'addMovie.defaults'
+];
+
+//
+// Actions Types
+
+export const LOOKUP_MOVIE = 'addMovie/lookupMovie';
+export const ADD_MOVIE = 'addMovie/addMovie';
+export const SET_ADD_MOVIE_VALUE = 'addMovie/setAddMovieValue';
+export const CLEAR_ADD_MOVIE = 'addMovie/clearAddMovie';
+export const SET_ADD_MOVIE_DEFAULT = 'addMovie/setAddMovieDefault';
+
+//
+// Action Creators
+
+export const lookupMovie = createThunk(LOOKUP_MOVIE);
+export const addMovie = createThunk(ADD_MOVIE);
+export const clearAddMovie = createAction(CLEAR_ADD_MOVIE);
+export const setAddMovieDefault = createAction(SET_ADD_MOVIE_DEFAULT);
+
+export const setAddMovieValue = createAction(SET_ADD_MOVIE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [LOOKUP_MOVIE]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ if (abortCurrentRequest) {
+ abortCurrentRequest();
+ }
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/movie/lookup',
+ data: {
+ term: payload.term
+ }
+ });
+
+ abortCurrentRequest = abortRequest;
+
+ request.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr.aborted ? null : xhr
+ }));
+ });
+ },
+
+ [ADD_MOVIE]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isAdding: true }));
+
+ const tmdbId = payload.tmdbId;
+ const items = getState().addMovie.items;
+ const newSeries = getNewMovie(_.cloneDeep(_.find(items, { tmdbId })), payload);
+
+ const promise = $.ajax({
+ url: '/movie',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(newSeries)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section: 'movies', ...data }),
+
+ set({
+ section,
+ isAdding: false,
+ isAdded: true,
+ addError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isAdding: false,
+ isAdded: false,
+ addError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ADD_MOVIE_VALUE]: createSetSettingValueReducer(section),
+
+ [SET_ADD_MOVIE_DEFAULT]: function(state, { payload }) {
+ const newState = getSectionState(state, section);
+
+ newState.defaults = {
+ ...newState.defaults,
+ ...payload
+ };
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [CLEAR_ADD_MOVIE]: function(state) {
+ const {
+ defaults,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js
new file mode 100644
index 000000000..66387468c
--- /dev/null
+++ b/frontend/src/Store/Actions/appActions.js
@@ -0,0 +1,135 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createHandleActions from './Creators/createHandleActions';
+
+function getDimensions(width, height) {
+ const dimensions = {
+ width,
+ height,
+ isExtraSmallScreen: width <= 480,
+ isSmallScreen: width <= 768,
+ isMediumScreen: width <= 992,
+ isLargeScreen: width <= 1200
+ };
+
+ return dimensions;
+}
+
+//
+// Variables
+
+export const section = 'app';
+const messagesSection = 'app.messages';
+
+//
+// State
+
+export const defaultState = {
+ dimensions: getDimensions(window.innerWidth, window.innerHeight),
+ messages: {
+ items: []
+ },
+ version: window.Radarr.version,
+ isUpdated: false,
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false,
+ isRestarting: false,
+ isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
+};
+
+//
+// Action Types
+
+export const SHOW_MESSAGE = 'app/showMessage';
+export const HIDE_MESSAGE = 'app/hideMessage';
+export const SAVE_DIMENSIONS = 'app/saveDimensions';
+export const SET_VERSION = 'app/setVersion';
+export const SET_APP_VALUE = 'app/setAppValue';
+export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible';
+
+//
+// Action Creators
+
+export const saveDimensions = createAction(SAVE_DIMENSIONS);
+export const setVersion = createAction(SET_VERSION);
+export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE);
+export const setAppValue = createAction(SET_APP_VALUE);
+export const showMessage = createAction(SHOW_MESSAGE);
+export const hideMessage = createAction(HIDE_MESSAGE);
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SAVE_DIMENSIONS]: function(state, { payload }) {
+ const {
+ width,
+ height
+ } = payload;
+
+ const dimensions = getDimensions(width, height);
+
+ return Object.assign({}, state, { dimensions });
+ },
+
+ [SHOW_MESSAGE]: function(state, { payload }) {
+ const newState = getSectionState(state, messagesSection);
+ const items = newState.items;
+ const index = _.findIndex(items, { id: payload.id });
+
+ newState.items = [...items];
+
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...payload });
+ } else {
+ newState.items.push({ ...payload });
+ }
+
+ return updateSectionState(state, messagesSection, newState);
+ },
+
+ [HIDE_MESSAGE]: function(state, { payload }) {
+ const newState = getSectionState(state, messagesSection);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ return updateSectionState(state, messagesSection, newState);
+ },
+
+ [SET_APP_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [SET_VERSION]: function(state, { payload }) {
+ const version = payload.version;
+
+ const newState = {
+ version
+ };
+
+ if (state.version !== version) {
+ newState.isUpdated = true;
+ }
+
+ return Object.assign({}, state, newState);
+ },
+
+ [SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) {
+ const newState = {
+ isSidebarVisible: payload.isSidebarVisible
+ };
+
+ return Object.assign({}, state, newState);
+ }
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js
new file mode 100644
index 000000000..37be3e0d2
--- /dev/null
+++ b/frontend/src/Store/Actions/baseActions.js
@@ -0,0 +1,29 @@
+import { createAction } from 'redux-actions';
+
+//
+// Action Types
+
+export const SET = 'base/set';
+
+export const UPDATE = 'base/update';
+export const UPDATE_ITEM = 'base/updateItem';
+export const UPDATE_SERVER_SIDE_COLLECTION = 'base/updateServerSideCollection';
+
+export const SET_SETTING_VALUE = 'base/setSettingValue';
+export const CLEAR_PENDING_CHANGES = 'base/clearPendingChanges';
+
+export const REMOVE_ITEM = 'base/removeItem';
+
+//
+// Action Creators
+
+export const set = createAction(SET);
+
+export const update = createAction(UPDATE);
+export const updateItem = createAction(UPDATE_ITEM);
+export const updateServerSideCollection = createAction(UPDATE_SERVER_SIDE_COLLECTION);
+
+export const setSettingValue = createAction(SET_SETTING_VALUE);
+export const clearPendingChanges = createAction(CLEAR_PENDING_CHANGES);
+
+export const removeItem = createAction(REMOVE_ITEM);
diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js
new file mode 100644
index 000000000..a6eb4be86
--- /dev/null
+++ b/frontend/src/Store/Actions/blacklistActions.js
@@ -0,0 +1,139 @@
+import { createAction } from 'redux-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { sortDirections } from 'Helpers/Props';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+
+//
+// Variables
+
+export const section = 'blacklist';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'movie.sortTitle',
+ label: 'Movie Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'blacklist.pageSize',
+ 'blacklist.sortKey',
+ 'blacklist.sortDirection',
+ 'blacklist.columns'
+];
+
+//
+// Action Types
+
+export const FETCH_BLACKLIST = 'blacklist/fetchBlacklist';
+export const GOTO_FIRST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistFirstPage';
+export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPreviousPage';
+export const GOTO_NEXT_BLACKLIST_PAGE = 'blacklist/gotoBlacklistNextPage';
+export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage';
+export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage';
+export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort';
+export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption';
+export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist';
+export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist';
+
+//
+// Action Creators
+
+export const fetchBlacklist = createThunk(FETCH_BLACKLIST);
+export const gotoBlacklistFirstPage = createThunk(GOTO_FIRST_BLACKLIST_PAGE);
+export const gotoBlacklistPreviousPage = createThunk(GOTO_PREVIOUS_BLACKLIST_PAGE);
+export const gotoBlacklistNextPage = createThunk(GOTO_NEXT_BLACKLIST_PAGE);
+export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE);
+export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE);
+export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT);
+export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION);
+export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST);
+export const clearBlacklist = createAction(CLEAR_BLACKLIST);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...createServerSideCollectionHandlers(
+ section,
+ '/blacklist',
+ fetchBlacklist,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_BLACKLIST,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT
+ }),
+
+ [REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist')
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_BLACKLIST]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js
new file mode 100644
index 000000000..4ac3d19ed
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActions.js
@@ -0,0 +1,389 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import moment from 'moment';
+import { filterTypes } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import * as calendarViews from 'Calendar/calendarViews';
+import * as commandNames from 'Commands/commandNames';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+import { executeCommandHelper } from './commandActions';
+
+//
+// Variables
+
+export const section = 'calendar';
+
+const viewRanges = {
+ [calendarViews.MONTH]: 'month',
+ [calendarViews.FORECAST]: 'day'
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ start: null,
+ end: null,
+ dates: [],
+ dayCount: 7,
+ view: window.innerWidth > 768 ? 'month' : 'day',
+ error: null,
+ items: [],
+ searchMissingCommandId: null,
+
+ options: {
+ collapseMultipleEpisodes: false,
+ showEpisodeInformation: true,
+ showFinaleIcon: false,
+ showSpecialIcon: false,
+ showCutoffUnmetIcon: false
+ },
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'monitored',
+ label: 'Monitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+
+ ]
+};
+
+export const persistState = [
+ 'calendar.view',
+ 'calendar.selectedFilterKey',
+ 'calendar.options'
+];
+
+//
+// Actions Types
+
+export const FETCH_CALENDAR = 'calendar/fetchCalendar';
+export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount';
+export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter';
+export const SET_CALENDAR_VIEW = 'calendar/setCalendarView';
+export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday';
+export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange';
+export const CLEAR_CALENDAR = 'calendar/clearCalendar';
+export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption';
+export const SEARCH_MISSING = 'calendar/searchMissing';
+export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange';
+
+//
+// Helpers
+
+function getDays(start, end) {
+ const startTime = moment(start);
+ const endTime = moment(end);
+ const difference = endTime.diff(startTime, 'days');
+
+ // Difference is one less than the number of days we need to account for.
+ return _.times(difference + 1, (i) => {
+ return startTime.clone().add(i, 'days').toISOString();
+ });
+}
+
+function getDates(time, view, firstDayOfWeek, dayCount) {
+ const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek';
+
+ let start = time.clone().startOf('day');
+ let end = time.clone().endOf('day');
+
+ if (view === calendarViews.WEEK) {
+ start = time.clone().startOf(weekName);
+ end = time.clone().endOf(weekName);
+ }
+
+ if (view === calendarViews.FORECAST) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(dayCount - 2, 'days').endOf('day');
+ }
+
+ if (view === calendarViews.MONTH) {
+ start = time.clone().startOf('month').startOf(weekName);
+ end = time.clone().endOf('month').endOf(weekName);
+ }
+
+ if (view === calendarViews.AGENDA) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(1, 'month').endOf('day');
+ }
+
+ return {
+ start: start.toISOString(),
+ end: end.toISOString(),
+ time: time.toISOString(),
+ dates: getDays(start, end)
+ };
+}
+
+function getPopulatableRange(startDate, endDate, view) {
+ switch (view) {
+ case calendarViews.DAY:
+ return {
+ start: moment(startDate).subtract(1, 'day').toISOString(),
+ end: moment(endDate).add(1, 'day').toISOString()
+ };
+ case calendarViews.WEEK:
+ case calendarViews.FORECAST:
+ return {
+ start: moment(startDate).subtract(1, 'week').toISOString(),
+ end: moment(endDate).add(1, 'week').toISOString()
+ };
+ default:
+ return {
+ start: startDate,
+ end: endDate
+ };
+ }
+}
+
+function isRangePopulated(start, end, state) {
+ const {
+ start: currentStart,
+ end: currentEnd,
+ view: currentView
+ } = state;
+
+ if (!currentStart || !currentEnd) {
+ return false;
+ }
+
+ const {
+ start: currentPopulatedStart,
+ end: currentPopulatedEnd
+ } = getPopulatableRange(currentStart, currentEnd, currentView);
+
+ if (
+ moment(start).isAfter(currentPopulatedStart) &&
+ moment(start).isBefore(currentPopulatedEnd)
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+//
+// Action Creators
+
+export const fetchCalendar = createThunk(FETCH_CALENDAR);
+export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT);
+export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER);
+export const setCalendarView = createThunk(SET_CALENDAR_VIEW);
+export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY);
+export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE);
+export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE);
+export const clearCalendar = createAction(CLEAR_CALENDAR);
+export const setCalendarOption = createAction(SET_CALENDAR_OPTION);
+export const searchMissing = createThunk(SEARCH_MISSING);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_CALENDAR]: function(getState, payload, dispatch) {
+ const state = getState();
+ const calendar = state.calendar;
+ const unmonitored = calendar.selectedFilterKey === 'all';
+
+ const {
+ time = calendar.time,
+ view = calendar.view
+ } = payload;
+
+ const dayCount = state.calendar.dayCount;
+ const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount);
+ const { start, end } = getPopulatableRange(dates.start, dates.end, view);
+ const isPrePopulated = isRangePopulated(start, end, state.calendar);
+
+ const basesAttrs = {
+ section,
+ isFetching: true
+ };
+
+ const attrs = isPrePopulated ?
+ {
+ view,
+ ...basesAttrs,
+ ...dates
+ } :
+ basesAttrs;
+
+ dispatch(set(attrs));
+
+ const promise = $.ajax({
+ url: '/calendar',
+ data: {
+ unmonitored,
+ start,
+ end
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ view,
+ ...dates,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) {
+ if (payload.dayCount === getState().calendar.dayCount) {
+ return;
+ }
+
+ dispatch(set({
+ section,
+ dayCount: payload.dayCount
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ selectedFilterKey: payload.selectedFilterKey
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) {
+ const state = getState();
+ const view = payload.view;
+ const time = view === calendarViews.FORECAST || calendarViews.AGENDA ?
+ moment() :
+ state.calendar.time;
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) {
+ const state = getState();
+ const view = state.calendar.view;
+ const time = moment();
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).subtract(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).add(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ },
+
+ [SEARCH_MISSING]: function(getState, payload, dispatch) {
+ const { episodeIds } = payload;
+
+ const commandPayload = {
+ name: commandNames.MOVIE_SEARCH,
+ episodeIds
+ };
+
+ executeCommandHelper(commandPayload, dispatch).then((data) => {
+ dispatch(set({
+ section,
+ searchMissingCommandId: data.id
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_CALENDAR]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }),
+
+ [SET_CALENDAR_OPTION]: function(state, { payload }) {
+ const options = state.options;
+
+ return {
+ ...state,
+ options: {
+ ...options,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js
new file mode 100644
index 000000000..d506566f7
--- /dev/null
+++ b/frontend/src/Store/Actions/captchaActions.js
@@ -0,0 +1,119 @@
+import { createAction } from 'redux-actions';
+import requestAction from 'Utilities/requestAction';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'captcha';
+
+//
+// State
+
+export const defaultState = {
+ refreshing: false,
+ token: null,
+ siteKey: null,
+ secretToken: null,
+ ray: null,
+ stoken: null,
+ responseUrl: null
+};
+
+//
+// Actions Types
+
+export const REFRESH_CAPTCHA = 'captcha/refreshCaptcha';
+export const GET_CAPTCHA_COOKIE = 'captcha/getCaptchaCookie';
+export const SET_CAPTCHA_VALUE = 'captcha/setCaptchaValue';
+export const RESET_CAPTCHA = 'captcha/resetCaptcha';
+
+//
+// Action Creators
+
+export const refreshCaptcha = createThunk(REFRESH_CAPTCHA);
+export const getCaptchaCookie = createThunk(GET_CAPTCHA_COOKIE);
+export const setCaptchaValue = createAction(SET_CAPTCHA_VALUE);
+export const resetCaptcha = createAction(RESET_CAPTCHA);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [REFRESH_CAPTCHA]: function(getState, payload, dispatch) {
+ const actionPayload = {
+ action: 'checkCaptcha',
+ ...payload
+ };
+
+ dispatch(setCaptchaValue({
+ refreshing: true
+ }));
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ if (!data.captchaRequest) {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ }
+
+ dispatch(setCaptchaValue({
+ refreshing: false,
+ ...data.captchaRequest
+ }));
+ });
+
+ promise.fail(() => {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ });
+ },
+
+ [GET_CAPTCHA_COOKIE]: function(getState, payload, dispatch) {
+ const state = getState().captcha;
+
+ const queryParams = {
+ responseUrl: state.responseUrl,
+ ray: state.ray,
+ captchaResponse: payload.captchaResponse
+ };
+
+ const actionPayload = {
+ action: 'getCaptchaCookie',
+ queryParams,
+ ...payload
+ };
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ dispatch(setCaptchaValue({
+ token: data.captchaToken
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_CAPTCHA_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [RESET_CAPTCHA]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState);
diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js
new file mode 100644
index 000000000..5a7baf58a
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActions.js
@@ -0,0 +1,215 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { isSameCommand } from 'Utilities/Command';
+import { messageTypes } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { showMessage, hideMessage } from './appActions';
+import { updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'commands';
+
+let lastCommand = null;
+let lastCommandTimeout = null;
+const removeCommandTimeoutIds = {};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ handlers: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_COMMANDS = 'commands/fetchCommands';
+export const EXECUTE_COMMAND = 'commands/executeCommand';
+export const CANCEL_COMMAND = 'commands/cancelCommand';
+export const ADD_COMMAND = 'commands/updateCommand';
+export const UPDATE_COMMAND = 'commands/finishCommand';
+export const FINISH_COMMAND = 'commands/addCommand';
+export const REMOVE_COMMAND = 'commands/removeCommand';
+
+//
+// Action Creators
+
+export const fetchCommands = createThunk(FETCH_COMMANDS);
+export const executeCommand = createThunk(EXECUTE_COMMAND);
+export const cancelCommand = createThunk(CANCEL_COMMAND);
+export const updateCommand = createThunk(UPDATE_COMMAND);
+export const finishCommand = createThunk(FINISH_COMMAND);
+export const addCommand = createAction(ADD_COMMAND);
+export const removeCommand = createAction(REMOVE_COMMAND);
+
+//
+// Helpers
+
+function showCommandMessage(payload, dispatch) {
+ const {
+ id,
+ name,
+ trigger,
+ message,
+ body = {},
+ status
+ } = payload;
+
+ const {
+ sendUpdatesToClient,
+ suppressMessages
+ } = body;
+
+ if (!message || !body || !sendUpdatesToClient || suppressMessages) {
+ return;
+ }
+
+ let type = messageTypes.INFO;
+ let hideAfter = 0;
+
+ if (status === 'completed') {
+ type = messageTypes.SUCCESS;
+ hideAfter = 4;
+ } else if (status === 'failed') {
+ type = messageTypes.ERROR;
+ hideAfter = trigger === 'manual' ? 10 : 4;
+ }
+
+ dispatch(showMessage({
+ id,
+ name,
+ message,
+ type,
+ hideAfter
+ }));
+}
+
+function scheduleRemoveCommand(command, dispatch) {
+ const {
+ id,
+ status
+ } = command;
+
+ if (status === 'queued') {
+ return;
+ }
+
+ const timeoutId = removeCommandTimeoutIds[id];
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ removeCommandTimeoutIds[id] = setTimeout(() => {
+ dispatch(batchActions([
+ removeCommand({ section: 'commands', id }),
+ hideMessage({ id })
+ ]));
+
+ delete removeCommandTimeoutIds[id];
+ }, 60000 * 5);
+}
+
+export function executeCommandHelper( payload, dispatch) {
+ // TODO: show a message for the user
+ if (lastCommand && isSameCommand(lastCommand, payload)) {
+ console.warn('Please wait at least 5 seconds before running this command again');
+ }
+
+ lastCommand = payload;
+
+ // clear last command after 5 seconds.
+ if (lastCommandTimeout) {
+ clearTimeout(lastCommandTimeout);
+ }
+
+ lastCommandTimeout = setTimeout(() => {
+ lastCommand = null;
+ }, 5000);
+
+ const promise = $.ajax({
+ url: '/command',
+ method: 'POST',
+ data: JSON.stringify(payload)
+ });
+
+ return promise.then((data) => {
+ dispatch(addCommand(data));
+ });
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_COMMANDS]: createFetchHandler('commands', '/command'),
+
+ [EXECUTE_COMMAND]: function(getState, payload, dispatch) {
+ executeCommandHelper(payload, dispatch);
+ },
+
+ [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'),
+
+ [UPDATE_COMMAND]: function(getState, payload, dispatch) {
+ dispatch(updateItem({ section: 'commands', ...payload }));
+
+ showCommandMessage(payload, dispatch);
+ scheduleRemoveCommand(payload, dispatch);
+ },
+
+ [FINISH_COMMAND]: function(getState, payload, dispatch) {
+ const state = getState();
+ const handlers = state.commands.handlers;
+
+ Object.keys(handlers).forEach((key) => {
+ const handler = handlers[key];
+
+ if (handler.name === payload.name) {
+ dispatch(handler.handler(payload));
+ }
+ });
+
+ dispatch(updateItem({ section: 'commands', ...payload }));
+ scheduleRemoveCommand(payload, dispatch);
+ showCommandMessage(payload, dispatch);
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [ADD_COMMAND]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ newState.items = [...state.items, payload];
+
+ return newState;
+ },
+
+ [REMOVE_COMMAND]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ newState.items = [...state.items];
+
+ const index = _.findIndex(newState.items, { id: payload.id });
+
+ if (index > -1) {
+ newState.items.splice(index, 1);
+ }
+
+ return newState;
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js
new file mode 100644
index 000000000..750c3ef6f
--- /dev/null
+++ b/frontend/src/Store/Actions/customFilterActions.js
@@ -0,0 +1,55 @@
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'customFilters';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: [],
+ pendingChanges: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters';
+export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter';
+export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter';
+
+//
+// Action Creators
+
+export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS);
+export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER);
+export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'),
+
+ [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'),
+
+ [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter')
+
+});
+
+//
+// Reducers
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/deviceActions.js b/frontend/src/Store/Actions/deviceActions.js
new file mode 100644
index 000000000..089d49bf3
--- /dev/null
+++ b/frontend/src/Store/Actions/deviceActions.js
@@ -0,0 +1,83 @@
+import { createAction } from 'redux-actions';
+import requestAction from 'Utilities/requestAction';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set } from './baseActions';
+
+//
+// Variables
+
+export const section = 'devices';
+
+//
+// State
+
+export const defaultState = {
+ items: [],
+ isFetching: false,
+ isPopulated: false,
+ error: false
+};
+
+//
+// Actions Types
+
+export const FETCH_DEVICES = 'devices/fetchDevices';
+export const CLEAR_DEVICES = 'devices/clearDevices';
+
+//
+// Action Creators
+
+export const fetchDevices = createThunk(FETCH_DEVICES);
+export const clearDevices = createAction(CLEAR_DEVICES);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_DEVICES]: function(getState, payload, dispatch) {
+ const actionPayload = {
+ action: 'getDevices',
+ ...payload
+ };
+
+ dispatch(set({
+ section,
+ isFetching: true
+ }));
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data.devices || []
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_DEVICES]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
new file mode 100644
index 000000000..81b229b61
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -0,0 +1,257 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import { updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'history';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'eventType',
+ columnLabel: 'Event Type',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'movie.sortTitle',
+ label: 'Movie',
+ 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: 'downloadClient',
+ label: 'Download Client',
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isVisible: false
+ },
+ {
+ name: 'releaseGroup',
+ label: 'Release Group',
+ isVisible: false
+ },
+ {
+ name: 'details',
+ columnLabel: 'Details',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'all',
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'grabbed',
+ label: 'Grabbed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '1',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'imported',
+ label: 'Imported',
+ filters: [
+ {
+ key: 'eventType',
+ value: '3',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'failed',
+ label: 'Failed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '4',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'deleted',
+ label: 'Deleted',
+ filters: [
+ {
+ key: 'eventType',
+ value: '5',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'renamed',
+ label: 'Renamed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '6',
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+
+};
+
+export const persistState = [
+ 'history.pageSize',
+ 'history.sortKey',
+ 'history.sortDirection',
+ 'history.selectedFilterKey'
+];
+
+//
+// Actions Types
+
+export const FETCH_HISTORY = 'history/fetchHistory';
+export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage';
+export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage';
+export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage';
+export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage';
+export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage';
+export const SET_HISTORY_SORT = 'history/setHistorySort';
+export const SET_HISTORY_FILTER = 'history/setHistoryFilter';
+export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption';
+export const CLEAR_HISTORY = 'history/clearHistory';
+export const MARK_AS_FAILED = 'history/markAsFailed';
+
+//
+// Action Creators
+
+export const fetchHistory = createThunk(FETCH_HISTORY);
+export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE);
+export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE);
+export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE);
+export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE);
+export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE);
+export const setHistorySort = createThunk(SET_HISTORY_SORT);
+export const setHistoryFilter = createThunk(SET_HISTORY_FILTER);
+export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION);
+export const clearHistory = createAction(CLEAR_HISTORY);
+export const markAsFailed = createThunk(MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...createServerSideCollectionHandlers(
+ section,
+ '/history',
+ fetchHistory,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_HISTORY,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER
+ }),
+
+ [MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const id = payload.id;
+
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: true
+ }));
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id
+ }
+ });
+
+ promise.done(() => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_HISTORY]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/importMovieActions.js b/frontend/src/Store/Actions/importMovieActions.js
new file mode 100644
index 000000000..e43f1e6b7
--- /dev/null
+++ b/frontend/src/Store/Actions/importMovieActions.js
@@ -0,0 +1,287 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import getNewMovie from 'Utilities/Movie/getNewMovie';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set, removeItem, updateItem } from './baseActions';
+import { fetchRootFolders } from './rootFolderActions';
+
+//
+// Variables
+
+export const section = 'importMovie';
+let concurrentLookups = 0;
+let abortCurrentLookup = null;
+const queue = [];
+
+//
+// State
+
+export const defaultState = {
+ isLookingUpMovie: false,
+ isImporting: false,
+ isImported: false,
+ importError: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const QUEUE_LOOKUP_MOVIE = 'importMovie/queueLookupMovie';
+export const START_LOOKUP_MOVIE = 'importMovie/startLookupMovie';
+export const CANCEL_LOOKUP_MOVIE = 'importMovie/cancelLookupMovie';
+export const CLEAR_IMPORT_MOVIE = 'importMovie/clearImportMovie';
+export const SET_IMPORT_MOVIE_VALUE = 'importMovie/setImportMovieValue';
+export const IMPORT_MOVIE = 'importMovie/importMovie';
+
+//
+// Action Creators
+
+export const queueLookupMovie = createThunk(QUEUE_LOOKUP_MOVIE);
+export const startLookupMovie = createThunk(START_LOOKUP_MOVIE);
+export const importMovie = createThunk(IMPORT_MOVIE);
+export const clearImportMovie = createAction(CLEAR_IMPORT_MOVIE);
+export const cancelLookupMovie = createAction(CANCEL_LOOKUP_MOVIE);
+
+export const setImportMovieValue = createAction(SET_IMPORT_MOVIE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [QUEUE_LOOKUP_MOVIE]: function(getState, payload, dispatch) {
+ const {
+ name,
+ path,
+ term,
+ topOfQueue = false
+ } = payload;
+
+ const state = getState().importMovie;
+ 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: []
+ }));
+
+ const itemIndex = queue.indexOf(item.id);
+
+ if (itemIndex >= 0) {
+ queue.splice(itemIndex, 1);
+ }
+
+ if (topOfQueue) {
+ queue.unshift(item.id);
+ } else {
+ queue.push(item.id);
+ }
+
+ if (term && term.length > 2) {
+ dispatch(startLookupMovie({ start: true }));
+ }
+ },
+
+ [START_LOOKUP_MOVIE]: function(getState, payload, dispatch) {
+ if (concurrentLookups >= 1) {
+ return;
+ }
+
+ const state = getState().importMovie;
+
+ const {
+ isLookingUpMovie,
+ items
+ } = state;
+
+ const queueId = queue[0];
+
+ if (payload.start && !isLookingUpMovie) {
+ dispatch(set({ section, isLookingUpMovie: true }));
+ } else if (!isLookingUpMovie) {
+ return;
+ } else if (!queueId) {
+ dispatch(set({ section, isLookingUpMovie: false }));
+ return;
+ }
+
+ concurrentLookups++;
+ queue.splice(0, 1);
+
+ const queued = items.find((i) => i.id === queueId);
+
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: true
+ }));
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/movie/lookup',
+ data: {
+ term: queued.term
+ }
+ });
+
+ abortCurrentLookup = abortRequest;
+
+ request.done((data) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data,
+ queued: false,
+ selectedMovie: queued.selectedMovie || data[0],
+ updateOnly: true
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr,
+ queued: false,
+ updateOnly: true
+ }));
+ });
+
+ request.always(() => {
+ concurrentLookups--;
+
+ dispatch(startLookupMovie());
+ });
+ },
+
+ [IMPORT_MOVIE]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isImporting: true }));
+
+ const ids = payload.ids;
+ const items = getState().importMovie.items;
+ const addedIds = [];
+
+ const allNewMovies = ids.reduce((acc, id) => {
+ const item = _.find(items, { id });
+ const selectedMovie = item.selectedMovie;
+
+ // Make sure we have a selected series and
+ // the same series hasn't been added yet.
+ if (selectedMovie && !_.some(acc, { tmdbId: selectedMovie.tmdbId })) {
+ const newSeries = getNewMovie(_.cloneDeep(selectedMovie), item);
+ newSeries.path = item.path;
+
+ addedIds.push(id);
+ acc.push(newSeries);
+ }
+
+ return acc;
+ }, []);
+
+ const promise = $.ajax({
+ url: '/movie/import',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(allNewMovies)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isImporting: false,
+ isImported: true
+ }),
+
+ ...data.map((series) => updateItem({ section: 'movies', ...series })),
+
+ ...addedIds.map((id) => removeItem({ section, id }))
+ ]));
+
+ dispatch(fetchRootFolders());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions(
+ set({
+ section,
+ isImporting: false,
+ isImported: true
+ }),
+
+ addedIds.map((id) => updateItem({
+ section,
+ id,
+ importError: xhr
+ }))
+ ));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CANCEL_LOOKUP_MOVIE]: function(state) {
+ return Object.assign({}, state, { isLookingUpMovie: false });
+ },
+
+ [CLEAR_IMPORT_MOVIE]: function(state) {
+ if (abortCurrentLookup) {
+ abortCurrentLookup();
+
+ abortCurrentLookup = null;
+ }
+
+ queue.splice(0, queue.length);
+
+ return Object.assign({}, state, defaultState);
+ },
+
+ [SET_IMPORT_MOVIE_VALUE]: function(state, { payload }) {
+ const newState = getSectionState(state, section);
+ const items = newState.items;
+ const index = _.findIndex(items, { id: payload.id });
+
+ newState.items = [...items];
+
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...payload });
+ } else {
+ newState.items.push({ ...payload });
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js
new file mode 100644
index 000000000..d27f9c5b9
--- /dev/null
+++ b/frontend/src/Store/Actions/index.js
@@ -0,0 +1,53 @@
+import * as addMovie from './addMovieActions';
+import * as app from './appActions';
+import * as blacklist from './blacklistActions';
+import * as calendar from './calendarActions';
+import * as captcha from './captchaActions';
+import * as customFilters from './customFilterActions';
+import * as devices from './deviceActions';
+import * as commands from './commandActions';
+import * as movieFiles from './movieFileActions';
+import * as history from './historyActions';
+import * as importMovie from './importMovieActions';
+import * as interactiveImportActions from './interactiveImportActions';
+import * as oAuth from './oAuthActions';
+import * as organizePreview from './organizePreviewActions';
+import * as paths from './pathActions';
+import * as queue from './queueActions';
+import * as releases from './releaseActions';
+import * as rootFolders from './rootFolderActions';
+import * as movies from './movieActions';
+import * as movieEditor from './movieEditorActions';
+import * as movieHistory from './movieHistoryActions';
+import * as movieIndex from './movieIndexActions';
+import * as settings from './settingsActions';
+import * as system from './systemActions';
+import * as tags from './tagActions';
+
+export default [
+ addMovie,
+ app,
+ blacklist,
+ calendar,
+ captcha,
+ commands,
+ customFilters,
+ devices,
+ movieFiles,
+ history,
+ importMovie,
+ interactiveImportActions,
+ oAuth,
+ organizePreview,
+ paths,
+ queue,
+ releases,
+ rootFolders,
+ movies,
+ movieEditor,
+ movieHistory,
+ movieIndex,
+ settings,
+ system,
+ tags
+];
diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js
new file mode 100644
index 000000000..9dd003d3f
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActions.js
@@ -0,0 +1,204 @@
+import $ from 'jquery';
+import moment from 'moment';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { sortDirections } from 'Helpers/Props';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'interactiveImport';
+
+const episodesSection = `${section}.episodes`;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'quality',
+ sortDirection: sortDirections.DESCENDING,
+ recentFolders: [],
+ importMode: 'move',
+ sortPredicates: {
+ relativePath: function(item, direction) {
+ const relativePath = item.relativePath;
+
+ return relativePath.toLowerCase();
+ },
+
+ series: function(item, direction) {
+ const series = item.series;
+
+ return series ? series.sortTitle : '';
+ },
+
+ quality: function(item, direction) {
+ return item.quality ? item.quality.qualityWeight : 0;
+ }
+ },
+
+ episodes: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'episodeNumber',
+ sortDirection: sortDirections.ASCENDING,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'interactiveImport.recentFolders',
+ 'interactiveImport.importMode'
+];
+
+//
+// Actions Types
+
+export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems';
+export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort';
+export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem';
+export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport';
+export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder';
+export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder';
+export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode';
+
+export const FETCH_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/fetchInteractiveImportEpisodes';
+export const SET_INTERACTIVE_IMPORT_EPISODES_SORT = 'interactiveImport/setInteractiveImportEpisodesSort';
+export const CLEAR_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/clearInteractiveImportEpisodes';
+
+//
+// Action Creators
+
+export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS);
+export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT);
+export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM);
+export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT);
+export const addRecentFolder = createAction(ADD_RECENT_FOLDER);
+export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER);
+export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE);
+
+export const fetchInteractiveImportEpisodes = createThunk(FETCH_INTERACTIVE_IMPORT_EPISODES);
+export const setInteractiveImportEpisodesSort = createAction(SET_INTERACTIVE_IMPORT_EPISODES_SORT);
+export const clearInteractiveImportEpisodes = createAction(CLEAR_INTERACTIVE_IMPORT_EPISODES);
+
+//
+// Action Handlers
+export const actionHandlers = handleThunks({
+ [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) {
+ if (!payload.downloadId && !payload.folder) {
+ dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } }));
+ return;
+ }
+
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = $.ajax({
+ url: '/manualimport',
+ data: payload
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [FETCH_INTERACTIVE_IMPORT_EPISODES]: createFetchHandler('interactiveImport.episodes', '/episode')
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => {
+ const id = payload.id;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+ const index = items.findIndex((item) => item.id === id);
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [ADD_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolder = { folder, lastUsed: moment().toISOString() };
+ const recentFolders = [...state.recentFolders];
+ const index = recentFolders.findIndex((r) => r.folder === folder);
+
+ if (index > -1) {
+ recentFolders.splice(index, 1, recentFolder);
+ } else {
+ recentFolders.push(recentFolder);
+ }
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [REMOVE_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolders = [...state.recentFolders];
+ const index = recentFolders.findIndex((r) => r.folder === folder);
+
+ recentFolders.splice(index, 1);
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [CLEAR_INTERACTIVE_IMPORT]: function(state) {
+ const newState = {
+ ...defaultState,
+ recentFolders: state.recentFolders,
+ importMode: state.importMode
+ };
+
+ return newState;
+ },
+
+ [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section),
+
+ [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) {
+ return Object.assign({}, state, { importMode: payload.importMode });
+ },
+
+ [SET_INTERACTIVE_IMPORT_EPISODES_SORT]: createSetClientSideCollectionSortReducer(episodesSection),
+
+ [CLEAR_INTERACTIVE_IMPORT_EPISODES]: (state) => {
+ return updateSectionState(state, episodesSection, {
+ ...defaultState.episodes
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/movieActions.js b/frontend/src/Store/Actions/movieActions.js
new file mode 100644
index 000000000..1015867ad
--- /dev/null
+++ b/frontend/src/Store/Actions/movieActions.js
@@ -0,0 +1,255 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+// import { batchActions } from 'redux-batched-actions';
+import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
+import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'movies';
+
+export const filters = [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'monitored',
+ label: 'Monitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: 'Unmonitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'wanted',
+ label: 'Wanted Missing',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ },
+ {
+ key: 'hasFile',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'cutoffunmet',
+ label: 'Cut-off Unmet',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ },
+ {
+ key: 'hasFile',
+ value: true,
+ type: filterTypes.EQUAL
+ },
+ {
+ key: 'movieFile.qualityCutoffNotMet',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+];
+
+export const filterPredicates = {
+ missing: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.episodeCount - statistics.episodeFileCount > 0;
+ },
+
+ added: function(item, filterValue, type) {
+ return dateFilterPredicate(item.added, filterValue, type);
+ },
+
+ ratings: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+
+ return predicate(item.ratings.value * 10, filterValue);
+ }
+};
+
+export const sortPredicates = {
+ status: function(item) {
+ let result = 0;
+
+ if (item.monitored) {
+ result += 2;
+ }
+
+ if (item.status === 'continuing') {
+ result++;
+ }
+
+ return result;
+ }
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ pendingChanges: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_MOVIES = 'movies/fetchMovies';
+export const SET_MOVIE_VALUE = 'movies/setMovieValue';
+export const SAVE_MOVIE = 'movies/saveMovie';
+export const DELETE_MOVIE = 'movies/deleteMovie';
+
+export const TOGGLE_MOVIE_MONITORED = 'movies/toggleMovieMonitored';
+
+//
+// Action Creators
+
+export const fetchMovies = createThunk(FETCH_MOVIES);
+export const saveMovie = createThunk(SAVE_MOVIE, (payload) => {
+ const newPayload = {
+ ...payload
+ };
+
+ if (payload.moveFiles) {
+ newPayload.queryParams = {
+ moveFiles: true
+ };
+ }
+
+ delete newPayload.moveFiles;
+
+ return newPayload;
+});
+
+export const deleteMovie = createThunk(DELETE_MOVIE, (payload) => {
+ return {
+ ...payload,
+ queryParams: {
+ deleteFiles: payload.deleteFiles
+ }
+ };
+});
+
+export const toggleMovieMonitored = createThunk(TOGGLE_MOVIE_MONITORED);
+
+export const setMovieValue = createAction(SET_MOVIE_VALUE, (payload) => {
+ return {
+ section: 'movies',
+ ...payload
+ };
+});
+
+//
+// Helpers
+
+function getSaveAjaxOptions({ ajaxOptions, payload }) {
+ if (payload.moveFolder) {
+ ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`;
+ }
+
+ return ajaxOptions;
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_MOVIES]: createFetchHandler(section, '/movie'),
+ [SAVE_MOVIE]: createSaveProviderHandler(section, '/movie', { getAjaxOptions: getSaveAjaxOptions }),
+ [DELETE_MOVIE]: createRemoveItemHandler(section, '/movie'),
+
+ [TOGGLE_MOVIE_MONITORED]: (getState, payload, dispatch) => {
+ const {
+ movieId: id,
+ monitored
+ } = payload;
+
+ const movie = _.find(getState().movies.items, { id });
+
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: `/movie/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...movie,
+ monitored
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_MOVIE_VALUE]: createSetSettingValueReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/movieEditorActions.js b/frontend/src/Store/Actions/movieEditorActions.js
new file mode 100644
index 000000000..4c775e838
--- /dev/null
+++ b/frontend/src/Store/Actions/movieEditorActions.js
@@ -0,0 +1,179 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set, updateItem } from './baseActions';
+import { filters, filterPredicates, sortPredicates } from './movieActions';
+
+//
+// Variables
+
+export const section = 'movieEditor';
+
+//
+// State
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ selectedFilterKey: 'all',
+ filters,
+ filterPredicates,
+
+ filterBuilderProps: [
+ {
+ name: 'monitored',
+ label: 'Monitored',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.SERIES_STATUS
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'rootFolderPath',
+ label: 'Root Folder Path',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ],
+
+ sortPredicates
+};
+
+export const persistState = [
+ 'movieEditor.sortKey',
+ 'movieEditor.sortDirection',
+ 'movieEditor.selectedFilterKey',
+ 'movieEditor.customFilters'
+];
+
+//
+// Actions Types
+
+export const SET_SERIES_EDITOR_SORT = 'movieEditor/setSeriesEditorSort';
+export const SET_SERIES_EDITOR_FILTER = 'movieEditor/setSeriesEditorFilter';
+export const SAVE_SERIES_EDITOR = 'movieEditor/saveSeriesEditor';
+export const BULK_DELETE_SERIES = 'movieEditor/bulkDeleteSeries';
+//
+// Action Creators
+
+export const setSeriesEditorSort = createAction(SET_SERIES_EDITOR_SORT);
+export const setSeriesEditorFilter = createAction(SET_SERIES_EDITOR_FILTER);
+export const saveSeriesEditor = createThunk(SAVE_SERIES_EDITOR);
+export const bulkDeleteSeries = createThunk(BULK_DELETE_SERIES);
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [SAVE_SERIES_EDITOR]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/series/editor',
+ method: 'PUT',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ ...data.map((series) => {
+ return updateItem({
+ id: series.id,
+ section: 'series',
+ ...series
+ });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ },
+
+ [BULK_DELETE_SERIES]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isDeleting: true
+ }));
+
+ const promise = $.ajax({
+ url: '/series/editor',
+ method: 'DELETE',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ // SignaR will take care of removing the series from the collection
+
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_SERIES_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_SERIES_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/movieFileActions.js b/frontend/src/Store/Actions/movieFileActions.js
new file mode 100644
index 000000000..a17a33ba8
--- /dev/null
+++ b/frontend/src/Store/Actions/movieFileActions.js
@@ -0,0 +1,210 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import movieEntities from 'Movie/movieEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { set, removeItem, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'movieFiles';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_MOVIE_FILES = 'movieFiles/fetchMovieFiles';
+export const DELETE_MOVIE_FILE = 'movieFiles/deleteMovieFile';
+export const DELETE_MOVIE_FILES = 'movieFiles/deleteMovieFiles';
+export const UPDATE_MOVIE_FILES = 'movieFiles/updateMovieFiles';
+export const CLEAR_MOVIE_FILES = 'movieFiles/clearMovieFiles';
+
+//
+// Action Creators
+
+export const fetchMovieFiles = createThunk(FETCH_MOVIE_FILES);
+export const deleteMovieFile = createThunk(DELETE_MOVIE_FILE);
+export const deleteMovieFiles = createThunk(DELETE_MOVIE_FILES);
+export const updateMovieFiles = createThunk(UPDATE_MOVIE_FILES);
+export const clearMovieFiles = createAction(CLEAR_MOVIE_FILES);
+
+//
+// Helpers
+
+const deleteMovieFileHelper = createRemoveItemHandler(section, '/movieFile');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_MOVIE_FILES]: createFetchHandler(section, '/movieFile'),
+
+ [DELETE_MOVIE_FILE]: function(getState, payload, dispatch) {
+ const {
+ id: movieFileId,
+ movieEntity = movieEntities.MOVIES
+ } = payload;
+
+ const movieSection = _.last(movieEntity.split('.'));
+ const deletePromise = deleteMovieFileHelper(getState, payload, dispatch);
+
+ deletePromise.done(() => {
+ const movies = getState().movies.items;
+ const moviesWithRemovedFiles = _.filter(movies, { movieFileId });
+
+ dispatch(batchActions([
+ ...moviesWithRemovedFiles.map((movie) => {
+ return updateItem({
+ section: movieSection,
+ ...movie,
+ movieFileId: 0,
+ hasFile: false
+ });
+ })
+ ]));
+ });
+ },
+
+ [DELETE_MOVIE_FILES]: function(getState, payload, dispatch) {
+ const {
+ movieFileIds
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const promise = $.ajax({
+ url: '/movieFile/bulk',
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ movieFileIds })
+ });
+
+ promise.done(() => {
+ const movies = getState().movies.items;
+ const moviesWithRemovedFiles = movieFileIds.reduce((acc, movieFileId) => {
+ acc.push(..._.filter(movies, { movieFileId }));
+
+ return acc;
+ }, []);
+
+ dispatch(batchActions([
+ ...movieFileIds.map((id) => {
+ return removeItem({ section, id });
+ }),
+
+ ...moviesWithRemovedFiles.map((movie) => {
+ return updateItem({
+ section: 'movies',
+ ...movie,
+ movieFileId: 0,
+ hasFile: false
+ });
+ }),
+
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ },
+
+ [UPDATE_MOVIE_FILES]: function(getState, payload, dispatch) {
+ const {
+ movieFileIds,
+ language,
+ quality
+ } = payload;
+
+ dispatch(set({ section, isSaving: true }));
+
+ const data = {
+ movieFileIds
+ };
+
+ if (language) {
+ data.language = language;
+ }
+
+ if (quality) {
+ data.quality = quality;
+ }
+
+ const promise = $.ajax({
+ url: '/movieFile/editor',
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(data)
+ });
+
+ promise.done(() => {
+ dispatch(batchActions([
+ ...movieFileIds.map((id) => {
+ const props = {};
+
+ if (language) {
+ props.language = language;
+ }
+
+ if (quality) {
+ props.quality = quality;
+ }
+
+ return updateItem({ section, id, ...props });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_MOVIE_FILES]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/movieHistoryActions.js b/frontend/src/Store/Actions/movieHistoryActions.js
new file mode 100644
index 000000000..f835b2009
--- /dev/null
+++ b/frontend/src/Store/Actions/movieHistoryActions.js
@@ -0,0 +1,104 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'movieHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_SERIES_HISTORY = 'seriesHistory/fetchMovieHistory';
+export const CLEAR_SERIES_HISTORY = 'seriesHistory/clearMovieHistory';
+export const SERIES_HISTORY_MARK_AS_FAILED = 'seriesHistory/seriesHistoryMarkAsFailed';
+
+//
+// Action Creators
+
+export const fetchMovieHistory = createThunk(FETCH_SERIES_HISTORY);
+export const clearMovieHistory = createAction(CLEAR_SERIES_HISTORY);
+export const seriesHistoryMarkAsFailed = createThunk(SERIES_HISTORY_MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_SERIES_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = $.ajax({
+ url: '/history/series',
+ data: payload
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [SERIES_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const {
+ historyId,
+ seriesId,
+ seasonNumber
+ } = payload;
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id: historyId
+ }
+ });
+
+ promise.done(() => {
+ dispatch(fetchMovieHistory({ seriesId, seasonNumber }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_SERIES_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js
new file mode 100644
index 000000000..007070f29
--- /dev/null
+++ b/frontend/src/Store/Actions/movieIndexActions.js
@@ -0,0 +1,320 @@
+import { createAction } from 'redux-actions';
+import sortByName from 'Utilities/Array/sortByName';
+import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { filters, filterPredicates, sortPredicates } from './movieActions';
+//
+// Variables
+
+export const section = 'movieIndex';
+
+//
+// State
+
+export const defaultState = {
+ sortKey: 'sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortTitle',
+ secondarySortDirection: sortDirections.ASCENDING,
+ view: 'posters',
+
+ posterOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: false,
+ showMonitored: true,
+ showQualityProfile: true,
+ showSearchAction: false
+ },
+
+ overviewOptions: {
+ detailedProgressBar: false,
+ size: 'medium',
+ showMonitored: true,
+ showStudio: true,
+ showQualityProfile: true,
+ showAdded: false,
+ showPath: false,
+ showSizeOnDisk: false,
+ showSearchAction: false
+ },
+
+ tableOptions: {
+ showSearchAction: false
+ },
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'sortTitle',
+ label: 'Movie Title',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'studio',
+ label: 'Studio',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'added',
+ label: 'Added',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'inCinemas',
+ label: 'In Cinemas',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'physicalRelease',
+ label: 'Physical Release',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'genres',
+ label: 'Genres',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'ratings',
+ label: 'Rating',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'certification',
+ label: 'Certification',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ sortPredicates: {
+ ...sortPredicates,
+
+ studio: function(item) {
+ const studio = item.studio;
+
+ return studio ? studio.toLowerCase() : '';
+ },
+
+ sizeOnDisk: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.sizeOnDisk;
+ },
+
+ ratings: function(item) {
+ const { ratings = {} } = item;
+
+ return ratings.value;
+ }
+ },
+
+ selectedFilterKey: 'all',
+
+ filters,
+ filterPredicates,
+
+ filterBuilderProps: [
+ {
+ name: 'monitored',
+ label: 'Monitored',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.SERIES_STATUS
+ },
+ {
+ name: 'studio',
+ label: 'Studio',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'added',
+ label: 'Added',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'inCinemas',
+ label: 'In Cinemas',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'physicalRelease',
+ label: 'Physical Release',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'sizeOnDisk',
+ label: 'Size on Disk',
+ type: filterBuilderTypes.NUMBER,
+ valueType: filterBuilderValueTypes.BYTES
+ },
+ {
+ name: 'genres',
+ label: 'Genres',
+ type: filterBuilderTypes.ARRAY,
+ optionsSelector: function(items) {
+ const tagList = items.reduce((acc, series) => {
+ series.genres.forEach((genre) => {
+ acc.push({
+ id: genre,
+ name: genre
+ });
+ });
+
+ return acc;
+ }, []);
+
+ return tagList.sort(sortByName);
+ }
+ },
+ {
+ name: 'ratings',
+ label: 'Rating',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'certification',
+ label: 'Certification',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ]
+};
+
+export const persistState = [
+ 'movieIndex.sortKey',
+ 'movieIndex.sortDirection',
+ 'movieIndex.selectedFilterKey',
+ 'movieIndex.customFilters',
+ 'movieIndex.view',
+ 'movieIndex.columns',
+ 'movieIndex.posterOptions',
+ 'movieIndex.overviewOptions',
+ 'movieIndex.tableOptions'
+];
+
+//
+// Actions Types
+
+export const SET_MOVIE_SORT = 'movieIndex/setMovieSort';
+export const SET_MOVIE_FILTER = 'movieIndex/setMovieFilter';
+export const SET_MOVIE_VIEW = 'movieIndex/setMovieView';
+export const SET_MOVIE_TABLE_OPTION = 'movieIndex/setMovieTableOption';
+export const SET_MOVIE_POSTER_OPTION = 'movieIndex/setMoviePosterOption';
+export const SET_MOVIE_OVERVIEW_OPTION = 'movieIndex/setMovieOverviewOption';
+
+//
+// Action Creators
+
+export const setMovieSort = createAction(SET_MOVIE_SORT);
+export const setMovieFilter = createAction(SET_MOVIE_FILTER);
+export const setMovieView = createAction(SET_MOVIE_VIEW);
+export const setMovieTableOption = createAction(SET_MOVIE_TABLE_OPTION);
+export const setMoviePosterOption = createAction(SET_MOVIE_POSTER_OPTION);
+export const setMovieOverviewOption = createAction(SET_MOVIE_OVERVIEW_OPTION);
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_MOVIE_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_MOVIE_FILTER]: createSetClientSideCollectionFilterReducer(section),
+
+ [SET_MOVIE_VIEW]: function(state, { payload }) {
+ return Object.assign({}, state, { view: payload.view });
+ },
+
+ [SET_MOVIE_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [SET_MOVIE_POSTER_OPTION]: function(state, { payload }) {
+ const posterOptions = state.posterOptions;
+
+ return {
+ ...state,
+ posterOptions: {
+ ...posterOptions,
+ ...payload
+ }
+ };
+ },
+
+ [SET_MOVIE_OVERVIEW_OPTION]: function(state, { payload }) {
+ const overviewOptions = state.overviewOptions;
+
+ return {
+ ...state,
+ overviewOptions: {
+ ...overviewOptions,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js
new file mode 100644
index 000000000..9ed9b46d6
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActions.js
@@ -0,0 +1,205 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import requestAction from 'Utilities/requestAction';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { set } from 'Store/Actions/baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'oAuth';
+const callbackUrl = `${window.location.origin}${window.Radarr.urlBase}/oauth.html`;
+
+//
+// State
+
+export const defaultState = {
+ authorizing: false,
+ result: null,
+ error: null
+};
+
+//
+// Actions Types
+
+export const START_OAUTH = 'oAuth/startOAuth';
+export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue';
+export const RESET_OAUTH = 'oAuth/resetOAuth';
+
+//
+// Action Creators
+
+export const startOAuth = createThunk(START_OAUTH);
+export const setOAuthValue = createAction(SET_OAUTH_VALUE);
+export const resetOAuth = createAction(RESET_OAUTH);
+
+//
+// Helpers
+
+function showOAuthWindow(url, payload) {
+ const deferred = $.Deferred();
+ const selfWindow = window;
+
+ const newWindow = window.open(url);
+
+ if (
+ !newWindow ||
+ newWindow.closed ||
+ typeof newWindow.closed == 'undefined'
+ ) {
+
+ // A fake validation error to mimic a 400 response from the API.
+ const error = {
+ status: 400,
+ responseJSON: [
+ {
+ propertyName: payload.name,
+ errorMessage: 'Pop-ups are being blocked by your browser'
+ }
+ ]
+ };
+
+ return deferred.reject(error).promise();
+ }
+
+ selfWindow.onCompleteOauth = function(query, onComplete) {
+ delete selfWindow.onCompleteOauth;
+
+ const queryParams = {};
+ const splitQuery = query.substring(1).split('&');
+
+ splitQuery.forEach((param) => {
+ if (param) {
+ const paramSplit = param.split('=');
+
+ queryParams[paramSplit[0]] = paramSplit[1];
+ }
+ });
+
+ onComplete();
+ deferred.resolve(queryParams);
+ };
+
+ return deferred.promise();
+}
+
+function executeIntermediateRequest(payload, ajaxOptions) {
+ return $.ajax(ajaxOptions).then((data) => {
+ return requestAction({
+ action: 'continueOAuth',
+ queryParams: {
+ ...data,
+ callbackUrl
+ },
+ ...payload
+ });
+ });
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [START_OAUTH]: function(getState, payload, dispatch) {
+ const {
+ name,
+ section: actionSection,
+ ...otherPayload
+ } = payload;
+
+ const actionPayload = {
+ action: 'startOAuth',
+ queryParams: { callbackUrl },
+ ...otherPayload
+ };
+
+ dispatch(setOAuthValue({
+ authorizing: true
+ }));
+
+ let startResponse = {};
+
+ const promise = requestAction(actionPayload)
+ .then((response) => {
+ startResponse = response;
+
+ if (response.oauthUrl) {
+ return showOAuthWindow(response.oauthUrl, payload);
+ }
+
+ return executeIntermediateRequest(otherPayload, response).then((intermediateResponse) => {
+ startResponse = intermediateResponse;
+
+ return showOAuthWindow(intermediateResponse.oauthUrl, payload);
+ });
+ })
+ .then((queryParams) => {
+ return requestAction({
+ action: 'getOAuthToken',
+ queryParams: {
+ ...startResponse,
+ ...queryParams
+ },
+ ...otherPayload
+ });
+ })
+ .then((response) => {
+ dispatch(setOAuthValue({
+ authorizing: false,
+ result: response,
+ error: null
+ }));
+ });
+
+ promise.done(() => {
+ // Clear any previously set save error.
+ dispatch(set({
+ section: actionSection,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ const actions = [
+ setOAuthValue({
+ authorizing: false,
+ result: null,
+ error: xhr
+ })
+ ];
+
+ if (xhr.status === 400) {
+ // Set a save error so the UI can display validation errors to the user.
+ actions.splice(0, 0, set({
+ section: actionSection,
+ saveError: xhr
+ }));
+ }
+
+ dispatch(batchActions(actions));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_OAUTH_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [RESET_OAUTH]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js
new file mode 100644
index 000000000..78f943f32
--- /dev/null
+++ b/frontend/src/Store/Actions/organizePreviewActions.js
@@ -0,0 +1,51 @@
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'organizePreview';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ORGANIZE_PREVIEW = 'organizePreview/fetchOrganizePreview';
+export const CLEAR_ORGANIZE_PREVIEW = 'organizePreview/clearOrganizePreview';
+
+//
+// Action Creators
+
+export const fetchOrganizePreview = createThunk(FETCH_ORGANIZE_PREVIEW);
+export const clearOrganizePreview = createAction(CLEAR_ORGANIZE_PREVIEW);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename')
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_ORGANIZE_PREVIEW]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js
new file mode 100644
index 000000000..129a9cb4d
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActions.js
@@ -0,0 +1,110 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set } from './baseActions';
+
+//
+// Variables
+
+export const section = 'paths';
+
+//
+// State
+
+export const defaultState = {
+ currentPath: '',
+ isPopulated: false,
+ isFetching: false,
+ error: null,
+ directories: [],
+ files: [],
+ parent: null
+};
+
+//
+// Actions Types
+
+export const FETCH_PATHS = 'paths/fetchPaths';
+export const UPDATE_PATHS = 'paths/updatePaths';
+export const CLEAR_PATHS = 'paths/clearPaths';
+
+//
+// Action Creators
+
+export const fetchPaths = createThunk(FETCH_PATHS);
+export const updatePaths = createAction(UPDATE_PATHS);
+export const clearPaths = createAction(CLEAR_PATHS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_PATHS]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ path,
+ allowFoldersWithoutTrailingSlashes = false
+ } = payload;
+
+ const promise = $.ajax({
+ url: '/filesystem',
+ data: {
+ path,
+ allowFoldersWithoutTrailingSlashes
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(updatePaths({ path, ...data }));
+
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [UPDATE_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.currentPath = payload.path;
+ newState.directories = payload.directories;
+ newState.files = payload.files;
+ newState.parent = payload.parent;
+
+ return newState;
+ },
+
+ [CLEAR_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.path = '';
+ newState.directories = [];
+ newState.files = [];
+ newState.parent = '';
+
+ return newState;
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js
new file mode 100644
index 000000000..2db89a75f
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActions.js
@@ -0,0 +1,439 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import { set, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'queue';
+const status = `${section}.status`;
+const details = `${section}.details`;
+const paged = `${section}.paged`;
+
+//
+// State
+
+export const defaultState = {
+ options: {
+ includeUnknownMovieItems: false
+ },
+
+ status: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ details: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ params: {}
+ },
+
+ paged: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'timeleft',
+ sortDirection: sortDirections.ASCENDING,
+ error: null,
+ items: [],
+ isGrabbing: false,
+ isRemoving: false,
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'series.sortTitle',
+ label: 'Series',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episode',
+ label: 'Episode',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episode.title',
+ label: 'Episode Title',
+ isVisible: true
+ },
+ {
+ name: 'episode.airDateUtc',
+ label: 'Episode Air Date',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'protocol',
+ label: 'Protocol',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'downloadClient',
+ label: 'Download Client',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'estimatedCompletionTime',
+ label: 'Timeleft',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ label: 'Progress',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'queue.options',
+ 'queue.paged.pageSize',
+ 'queue.paged.sortKey',
+ 'queue.paged.sortDirection',
+ 'queue.paged.columns'
+];
+
+//
+// Helpers
+
+function fetchDataAugmenter(getState, payload, data) {
+ data.includeUnknownMovieItems = getState().queue.options.includeUnknownMovieItems;
+}
+
+//
+// Actions Types
+
+export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
+
+export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
+export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
+
+export const FETCH_QUEUE = 'queue/fetchQueue';
+export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
+export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
+export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
+export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
+export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
+export const SET_QUEUE_SORT = 'queue/setQueueSort';
+export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
+export const SET_QUEUE_OPTION = 'queue/setQueueOption';
+export const CLEAR_QUEUE = 'queue/clearQueue';
+
+export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
+export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
+export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
+export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
+
+//
+// Action Creators
+
+export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
+
+export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
+export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
+
+export const fetchQueue = createThunk(FETCH_QUEUE);
+export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
+export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
+export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
+export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
+export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
+export const setQueueSort = createThunk(SET_QUEUE_SORT);
+export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
+export const setQueueOption = createAction(SET_QUEUE_OPTION);
+export const clearQueue = createAction(CLEAR_QUEUE);
+
+export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
+export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
+export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
+export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
+
+//
+// Helpers
+
+const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
+
+ [FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
+ let params = payload;
+
+ // If the payload params are empty try to get params from state.
+
+ if (params && !_.isEmpty(params)) {
+ dispatch(set({ section: details, params }));
+ } else {
+ params = getState().queue.details.params;
+ }
+
+ // Ensure there are params before trying to fetch the queue
+ // so we don't make a bad request to the server.
+
+ if (params && !_.isEmpty(params)) {
+ fetchQueueDetailsHelper(getState, params, dispatch);
+ }
+ },
+
+ ...createServerSideCollectionHandlers(
+ paged,
+ '/queue',
+ fetchQueue,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT
+ },
+ fetchDataAugmenter
+ ),
+
+ [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
+ const id = payload.id;
+
+ dispatch(updateItem({ section: paged, id, isGrabbing: true }));
+
+ const promise = $.ajax({
+ url: `/queue/grab/${id}`,
+ method: 'POST'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ fetchQueue(),
+
+ set({
+ section: paged,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: xhr
+ }));
+ });
+ },
+
+ [GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
+ const ids = payload.ids;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: true
+ });
+ }),
+
+ set({
+ section: paged,
+ isGrabbing: true
+ })
+ ]));
+
+ const promise = $.ajax({
+ url: '/queue/grab/bulk',
+ method: 'POST',
+ dataType: 'json',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ fetchQueue(),
+
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({
+ section: paged,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({ section: paged, isGrabbing: false })
+ ]));
+ });
+ },
+
+ [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
+ const {
+ id,
+ blacklist
+ } = payload;
+
+ dispatch(updateItem({ section: paged, id, isRemoving: true }));
+
+ const promise = $.ajax({
+ url: `/queue/${id}?blacklist=${blacklist}`,
+ method: 'DELETE'
+ });
+
+ promise.done((data) => {
+ dispatch(fetchQueue());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({ section: paged, id, isRemoving: false }));
+ });
+ },
+
+ [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
+ const {
+ ids,
+ blacklist
+ } = payload;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isRemoving: true
+ });
+ }),
+
+ set({ section: paged, isRemoving: true })
+ ]));
+
+ const promise = $.ajax({
+ url: `/queue/bulk?blacklist=${blacklist}`,
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ ids })
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({ section: paged, isRemoving: false }),
+ fetchQueue()
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isRemoving: false
+ });
+ }),
+
+ set({ section: paged, isRemoving: false })
+ ]));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
+
+ [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
+
+ [SET_QUEUE_OPTION]: function(state, { payload }) {
+ const queueOptions = state.options;
+
+ return {
+ ...state,
+ options: {
+ ...queueOptions,
+ ...payload
+ }
+ };
+ },
+
+ [CLEAR_QUEUE]: createClearReducer(paged, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/reducers.js b/frontend/src/Store/Actions/reducers.js
new file mode 100644
index 000000000..0254cd226
--- /dev/null
+++ b/frontend/src/Store/Actions/reducers.js
@@ -0,0 +1,20 @@
+import { combineReducers } from 'redux';
+import { enableBatching } from 'redux-batched-actions';
+import { routerReducer } from 'react-router-redux';
+import actions from 'Store/Actions';
+
+const defaultState = {};
+
+const reducers = {
+ routing: routerReducer
+};
+
+actions.forEach((action) => {
+ const section = action.section;
+
+ defaultState[section] = action.defaultState;
+ reducers[section] = action.reducers;
+});
+
+export { defaultState };
+export default enableBatching(combineReducers(reducers));
diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
new file mode 100644
index 000000000..1ea4fa6b3
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -0,0 +1,280 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'releases';
+export const episodeSection = 'releases.episode';
+export const seasonSection = 'releases.season';
+
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'releaseWeight',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ peers: function(item, direction) {
+ const seeders = item.seeders || 0;
+ const leechers = item.leechers || 0;
+
+ return seeders * 1000000 + leechers;
+ },
+
+ rejections: function(item, direction) {
+ const rejections = item.rejections;
+ const releaseWeight = item.releaseWeight;
+
+ if (rejections.length !== 0) {
+ return releaseWeight + 1000000;
+ }
+
+ return releaseWeight;
+ }
+ },
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'season-pack',
+ label: 'Season Pack',
+ filters: [
+ {
+ key: 'fullSeason',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'not-season-pack',
+ label: 'Not Season Pack',
+ filters: [
+ {
+ key: 'fullSeason',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ],
+
+ filterPredicates: {
+ quality: function(item, value, type) {
+ const qualityId = item.quality.quality.id;
+
+ if (type === filterTypes.EQUAL) {
+ return qualityId === value;
+ }
+
+ if (type === filterTypes.NOT_EQUAL) {
+ return qualityId !== value;
+ }
+
+ // Default to false
+ return false;
+ }
+ },
+
+ filterBuilderProps: [
+ {
+ name: 'title',
+ label: 'Title',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'age',
+ label: 'Age',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'protocol',
+ label: 'Protocol',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.PROTOCOL
+ },
+ {
+ name: 'indexerId',
+ label: 'Indexer',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.INDEXER
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'seeders',
+ label: 'Seeders',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'peers',
+ label: 'Peers',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY
+ },
+ {
+ name: 'rejections',
+ label: 'Rejections',
+ type: filterBuilderTypes.NUMBER
+ }
+ ],
+
+ episode: {
+ selectedFilterKey: 'all'
+ },
+
+ season: {
+ selectedFilterKey: 'season-pack'
+ }
+};
+
+export const persistState = [
+ 'releases.selectedFilterKey',
+ 'releases.episode.customFilters',
+ 'releases.season.customFilters'
+];
+
+//
+// Actions Types
+
+export const FETCH_RELEASES = 'releases/fetchReleases';
+export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases';
+export const SET_RELEASES_SORT = 'releases/setReleasesSort';
+export const CLEAR_RELEASES = 'releases/clearReleases';
+export const GRAB_RELEASE = 'releases/grabRelease';
+export const UPDATE_RELEASE = 'releases/updateRelease';
+export const SET_EPISODE_RELEASES_FILTER = 'releases/setEpisodeReleasesFilter';
+export const SET_SEASON_RELEASES_FILTER = 'releases/setSeasonReleasesFilter';
+
+//
+// Action Creators
+
+export const fetchReleases = createThunk(FETCH_RELEASES);
+export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES);
+export const setReleasesSort = createAction(SET_RELEASES_SORT);
+export const clearReleases = createAction(CLEAR_RELEASES);
+export const grabRelease = createThunk(GRAB_RELEASE);
+export const updateRelease = createAction(UPDATE_RELEASE);
+export const setEpisodeReleasesFilter = createAction(SET_EPISODE_RELEASES_FILTER);
+export const setSeasonReleasesFilter = createAction(SET_SEASON_RELEASES_FILTER);
+
+//
+// Helpers
+
+const fetchReleasesHelper = createFetchHandler(section, '/release');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_RELEASES]: function(getState, payload, dispatch) {
+ const abortRequest = fetchReleasesHelper(getState, payload, dispatch);
+
+ abortCurrentRequest = abortRequest;
+ },
+
+ [CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) {
+ if (abortCurrentRequest) {
+ abortCurrentRequest = abortCurrentRequest();
+ }
+ },
+
+ [GRAB_RELEASE]: function(getState, payload, dispatch) {
+ const guid = payload.guid;
+
+ dispatch(updateRelease({ guid, isGrabbing: true }));
+
+ const promise = $.ajax({
+ url: '/release',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: true,
+ grabError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue';
+
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: false,
+ grabError
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RELEASES]: (state) => {
+ const {
+ episode,
+ season,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ },
+
+ [UPDATE_RELEASE]: (state, { payload }) => {
+ const guid = payload.guid;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+
+ // Return early if there aren't any items (the user closed the modal)
+ if (!items.length) {
+ return;
+ }
+
+ const index = items.findIndex((item) => item.guid === guid);
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_EPISODE_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(episodeSection),
+ [SET_SEASON_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(seasonSection)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js
new file mode 100644
index 000000000..8180cdc7d
--- /dev/null
+++ b/frontend/src/Store/Actions/rootFolderActions.js
@@ -0,0 +1,97 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { set, updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'rootFolders';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders';
+export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder';
+export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder';
+
+//
+// Action Creators
+
+export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS);
+export const addRootFolder = createThunk(ADD_ROOT_FOLDER);
+export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'),
+
+ [DELETE_ROOT_FOLDER]: createRemoveItemHandler(
+ 'rootFolders',
+ '/rootFolder',
+ (state) => state.rootFolders
+ ),
+
+ [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) {
+ const path = payload.path;
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/rootFolder',
+ method: 'POST',
+ data: JSON.stringify({ path }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({
+ section,
+ ...data
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js
new file mode 100644
index 000000000..0dab4fb54
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActions.js
@@ -0,0 +1,139 @@
+import { createAction } from 'redux-actions';
+import { handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import delayProfiles from './Settings/delayProfiles';
+import downloadClients from './Settings/downloadClients';
+import downloadClientOptions from './Settings/downloadClientOptions';
+import general from './Settings/general';
+import indexerOptions from './Settings/indexerOptions';
+import indexers from './Settings/indexers';
+import netImportOptions from './Settings/netImportOptions';
+import netImports from './Settings/netImports';
+import mediaManagement from './Settings/mediaManagement';
+import metadata from './Settings/metadata';
+import naming from './Settings/naming';
+import namingExamples from './Settings/namingExamples';
+import notifications from './Settings/notifications';
+import qualityDefinitions from './Settings/qualityDefinitions';
+import qualityProfiles from './Settings/qualityProfiles';
+import remotePathMappings from './Settings/remotePathMappings';
+import restrictions from './Settings/restrictions';
+import ui from './Settings/ui';
+
+export * from './Settings/delayProfiles';
+export * from './Settings/downloadClients';
+export * from './Settings/downloadClientOptions';
+export * from './Settings/general';
+export * from './Settings/indexerOptions';
+export * from './Settings/indexers';
+export * from './Settings/netImportOptions';
+export * from './Settings/netImports';
+export * from './Settings/mediaManagement';
+export * from './Settings/metadata';
+export * from './Settings/naming';
+export * from './Settings/namingExamples';
+export * from './Settings/notifications';
+export * from './Settings/qualityDefinitions';
+export * from './Settings/qualityProfiles';
+export * from './Settings/remotePathMappings';
+export * from './Settings/restrictions';
+export * from './Settings/ui';
+
+//
+// Variables
+
+export const section = 'settings';
+
+//
+// State
+
+export const defaultState = {
+ advancedSettings: false,
+
+ delayProfiles: delayProfiles.defaultState,
+ downloadClients: downloadClients.defaultState,
+ downloadClientOptions: downloadClientOptions.defaultState,
+ general: general.defaultState,
+ indexerOptions: indexerOptions.defaultState,
+ indexers: indexers.defaultState,
+ netImportOptions: netImportOptions.defaultState,
+ netImports: netImports.defaultState,
+ mediaManagement: mediaManagement.defaultState,
+ metadata: metadata.defaultState,
+ naming: naming.defaultState,
+ namingExamples: namingExamples.defaultState,
+ notifications: notifications.defaultState,
+ qualityDefinitions: qualityDefinitions.defaultState,
+ qualityProfiles: qualityProfiles.defaultState,
+ remotePathMappings: remotePathMappings.defaultState,
+ restrictions: restrictions.defaultState,
+ ui: ui.defaultState
+};
+
+export const persistState = [
+ 'settings.advancedSettings'
+];
+
+//
+// Actions Types
+
+export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings';
+
+//
+// Action Creators
+
+export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...delayProfiles.actionHandlers,
+ ...downloadClients.actionHandlers,
+ ...downloadClientOptions.actionHandlers,
+ ...general.actionHandlers,
+ ...indexerOptions.actionHandlers,
+ ...indexers.actionHandlers,
+ ...netImportOptions.actionHandlers,
+ ...netImports.actionHandlers,
+ ...mediaManagement.actionHandlers,
+ ...metadata.actionHandlers,
+ ...naming.actionHandlers,
+ ...namingExamples.actionHandlers,
+ ...notifications.actionHandlers,
+ ...qualityDefinitions.actionHandlers,
+ ...qualityProfiles.actionHandlers,
+ ...remotePathMappings.actionHandlers,
+ ...restrictions.actionHandlers,
+ ...ui.actionHandlers
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => {
+ return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
+ },
+
+ ...delayProfiles.reducers,
+ ...downloadClients.reducers,
+ ...downloadClientOptions.reducers,
+ ...general.reducers,
+ ...indexerOptions.reducers,
+ ...indexers.reducers,
+ ...netImportOptions.reducers,
+ ...netImports.reducers,
+ ...mediaManagement.reducers,
+ ...metadata.reducers,
+ ...naming.reducers,
+ ...namingExamples.reducers,
+ ...notifications.reducers,
+ ...qualityDefinitions.reducers,
+ ...qualityProfiles.reducers,
+ ...remotePathMappings.reducers,
+ ...restrictions.reducers,
+ ...ui.reducers
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js
new file mode 100644
index 000000000..a7c3119b7
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActions.js
@@ -0,0 +1,392 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { setAppValue } from 'Store/Actions/appActions';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import { set } from './baseActions';
+
+//
+// Variables
+
+export const section = 'system';
+const backupsSection = 'system.backups';
+
+//
+// State
+
+export const defaultState = {
+ status: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ health: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ diskSpace: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ tasks: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ backups: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isRestoring: false,
+ restoreError: null,
+ isDeleting: false,
+ deleteError: null,
+ items: []
+ },
+
+ updates: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ logs: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 50,
+ sortKey: 'time',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'level',
+ columnLabel: 'Level',
+ isSortable: false,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'logger',
+ label: 'Component',
+ isSortable: false,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'time',
+ label: 'Time',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'all',
+
+ filters: [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'info',
+ label: 'Info',
+ filters: [
+ {
+ key: 'level',
+ value: 'info',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'warn',
+ label: 'Warn',
+ filters: [
+ {
+ key: 'level',
+ value: 'warn',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'error',
+ label: 'Error',
+ filters: [
+ {
+ key: 'level',
+ value: 'error',
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ },
+
+ logFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ updateLogFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'system.logs.pageSize',
+ 'system.logs.sortKey',
+ 'system.logs.sortDirection',
+ 'system.logs.selectedFilterKey'
+];
+
+//
+// Actions Types
+
+export const FETCH_STATUS = 'system/status/fetchStatus';
+export const FETCH_HEALTH = 'system/health/fetchHealth';
+export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace';
+
+export const FETCH_TASK = 'system/tasks/fetchTask';
+export const FETCH_TASKS = 'system/tasks/fetchTasks';
+
+export const FETCH_BACKUPS = 'system/backups/fetchBackups';
+export const RESTORE_BACKUP = 'system/backups/restoreBackup';
+export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
+export const DELETE_BACKUP = 'system/backups/deleteBackup';
+
+export const FETCH_UPDATES = 'system/updates/fetchUpdates';
+
+export const FETCH_LOGS = 'system/logs/fetchLogs';
+export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage';
+export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage';
+export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage';
+export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage';
+export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage';
+export const SET_LOGS_SORT = 'system/logs/setLogsSort';
+export const SET_LOGS_FILTER = 'system/logs/setLogsFilter';
+export const SET_LOGS_TABLE_OPTION = 'system/logs/setLogsTableOption';
+export const CLEAR_LOGS_TABLE = 'system/logs/clearLogsTable';
+
+export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles';
+export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles';
+
+export const RESTART = 'system/restart';
+export const SHUTDOWN = 'system/shutdown';
+
+//
+// Action Creators
+
+export const fetchStatus = createThunk(FETCH_STATUS);
+export const fetchHealth = createThunk(FETCH_HEALTH);
+export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE);
+
+export const fetchTask = createThunk(FETCH_TASK);
+export const fetchTasks = createThunk(FETCH_TASKS);
+
+export const fetchBackups = createThunk(FETCH_BACKUPS);
+export const restoreBackup = createThunk(RESTORE_BACKUP);
+export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP);
+export const deleteBackup = createThunk(DELETE_BACKUP);
+
+export const fetchUpdates = createThunk(FETCH_UPDATES);
+
+export const fetchLogs = createThunk(FETCH_LOGS);
+export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE);
+export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE);
+export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE);
+export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE);
+export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE);
+export const setLogsSort = createThunk(SET_LOGS_SORT);
+export const setLogsFilter = createThunk(SET_LOGS_FILTER);
+export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION);
+export const clearLogsTable = createAction(CLEAR_LOGS_TABLE);
+
+export const fetchLogFiles = createThunk(FETCH_LOG_FILES);
+export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES);
+
+export const restart = createThunk(RESTART);
+export const shutdown = createThunk(SHUTDOWN);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_STATUS]: createFetchHandler('system.status', '/system/status'),
+ [FETCH_HEALTH]: createFetchHandler('system.health', '/health'),
+ [FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'),
+ [FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'),
+ [FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'),
+
+ [FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'),
+
+ [RESTORE_BACKUP]: function(getState, payload, dispatch) {
+ const {
+ id,
+ file
+ } = payload;
+
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: true
+ }));
+
+ let ajaxOptions = null;
+
+ if (id) {
+ ajaxOptions = {
+ url: `/system/backup/restore/${id}`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify({
+ id
+ })
+ };
+ } else if (file) {
+ const formData = new FormData();
+ formData.append('restore', file);
+
+ ajaxOptions = {
+ url: '/system/backup/restore/upload',
+ method: 'POST',
+ processData: false,
+ contentType: false,
+ data: formData
+ };
+ } else {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: 'Error restoring backup'
+ }));
+ }
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section: backupsSection,
+ isRestoring: false,
+ restoreError: xhr
+ }));
+ });
+ },
+
+ [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
+
+ [FETCH_UPDATES]: createFetchHandler('system.updates', '/update'),
+ [FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
+ [FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
+
+ ...createServerSideCollectionHandlers(
+ 'system.logs',
+ '/log',
+ fetchLogs,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_LOGS,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_LOGS_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER
+ }
+ ),
+
+ [RESTART]: function(getState, payload, dispatch) {
+ const promise = $.ajax({
+ url: '/system/restart',
+ method: 'POST'
+ });
+
+ promise.done(() => {
+ dispatch(setAppValue({ isRestarting: true }));
+ });
+ },
+
+ [SHUTDOWN]: function() {
+ $.ajax({
+ url: '/system/shutdown',
+ method: 'POST'
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RESTORE_BACKUP]: function(state, { payload }) {
+ return {
+ ...state,
+ backups: {
+ ...state.backups,
+ isRestoring: false,
+ restoreError: null
+ }
+ };
+ },
+
+ [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs'),
+
+ [CLEAR_LOGS_TABLE]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js
new file mode 100644
index 000000000..b9d217bd3
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActions.js
@@ -0,0 +1,75 @@
+import $ from 'jquery';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'tags';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+
+ details: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }
+};
+
+//
+// Actions Types
+
+export const FETCH_TAGS = 'tags/fetchTags';
+export const ADD_TAG = 'tags/addTag';
+export const DELETE_TAG = 'tags/deleteTag';
+export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails';
+
+//
+// Action Creators
+
+export const fetchTags = createThunk(FETCH_TAGS);
+export const addTag = createThunk(ADD_TAG);
+export const deleteTag = createThunk(DELETE_TAG);
+export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_TAGS]: createFetchHandler(section, '/tag'),
+
+ [ADD_TAG]: function(getState, payload, dispatch) {
+ const promise = $.ajax({
+ url: '/tag',
+ method: 'POST',
+ data: JSON.stringify(payload.tag)
+ });
+
+ promise.done((data) => {
+ const tags = getState().tags.items.slice();
+ tags.push(data);
+
+ dispatch(update({ section, data: tags }));
+ payload.onTagCreated(data);
+ });
+ },
+
+ [DELETE_TAG]: createRemoveItemHandler(section, '/tag'),
+ [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail')
+
+});
+
+//
+// Reducers
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js
new file mode 100644
index 000000000..c7415c8ec
--- /dev/null
+++ b/frontend/src/Store/Middleware/createPersistState.js
@@ -0,0 +1,99 @@
+import _ from 'lodash';
+import persistState from 'redux-localstorage';
+import actions from 'Store/Actions';
+
+const columnPaths = [];
+
+const paths = _.reduce([...actions], (acc, action) => {
+ if (action.persistState) {
+ action.persistState.forEach((path) => {
+ if (path.match(/\.columns$/)) {
+ columnPaths.push(path);
+ }
+
+ acc.push(path);
+ });
+ }
+
+ return acc;
+}, []);
+
+function mergeColumns(path, initialState, persistedState, computedState) {
+ const initialColumns = _.get(initialState, path);
+ const persistedColumns = _.get(persistedState, path);
+
+ if (!persistedColumns || !persistedColumns.length) {
+ return;
+ }
+
+ const columns = [];
+
+ initialColumns.forEach((initialColumn) => {
+ const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name });
+ const column = Object.assign({}, initialColumn);
+ const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined;
+
+ if (persistedColumn) {
+ column.isVisible = persistedColumn.isVisible;
+ }
+
+ // If there is a persisted column, it's index doesn't exceed the column list
+ // and it's modifiable, insert it in the proper position.
+
+ if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) {
+ columns.splice(persistedColumnIndex, 0, column);
+ } else {
+ columns.push(column);
+ }
+
+ // Set the columns in the persisted state
+ _.set(computedState, path, columns);
+ });
+}
+
+function slicer(paths_) {
+ return (state) => {
+ const subset = {};
+
+ paths_.forEach((path) => {
+ _.set(subset, path, _.get(state, path));
+ });
+
+ return subset;
+ };
+}
+
+function serialize(obj) {
+ return JSON.stringify(obj, null, 2);
+}
+
+function merge(initialState, persistedState) {
+ if (!persistedState) {
+ return initialState;
+ }
+
+ const computedState = {};
+
+ _.merge(computedState, initialState, persistedState);
+
+ columnPaths.forEach((columnPath) => {
+ mergeColumns(columnPath, initialState, persistedState, computedState);
+ });
+
+ return computedState;
+}
+
+const config = {
+ slicer,
+ serialize,
+ merge,
+ key: 'radarr'
+};
+
+export default function createPersistState() {
+ // Migrate existing local storage before proceeding
+ const persistedState = JSON.parse(localStorage.getItem(config.key));
+ localStorage.setItem(config.key, serialize(persistedState));
+
+ return persistState(paths, config);
+}
diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js
new file mode 100644
index 000000000..afac7e701
--- /dev/null
+++ b/frontend/src/Store/Middleware/createSentryMiddleware.js
@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import * as sentry from '@sentry/browser';
+import parseUrl from 'Utilities/String/parseUrl';
+
+function cleanseUrl(url) {
+ const properties = parseUrl(url);
+
+ return `${properties.pathname}${properties.search}`;
+}
+
+function cleanseData(data) {
+ const result = _.cloneDeep(data);
+
+ result.transaction = cleanseUrl(result.transaction);
+
+ if (result.exception) {
+ result.exception.values.forEach((exception) => {
+ const stacktrace = exception.stacktrace;
+
+ if (stacktrace) {
+ stacktrace.frames.forEach((frame) => {
+ frame.filename = cleanseUrl(frame.filename);
+ });
+ }
+ });
+ }
+
+ result.request.url = cleanseUrl(result.request.url);
+
+ return result;
+}
+
+function identity(stuff) {
+ return stuff;
+}
+
+function createMiddleware() {
+ return (store) => (next) => (action) => {
+ try {
+ // Adds a breadcrumb for reporting later (if necessary).
+ sentry.addBreadcrumb({
+ category: 'redux',
+ message: action.type
+ });
+
+ return next(action);
+ } catch (err) {
+ console.error(`[sentry] Reporting error to Sentry: ${err}`);
+
+ // Send the report including breadcrumbs.
+ sentry.captureException(err, {
+ extra: {
+ action: identity(action),
+ state: identity(store.getState())
+ }
+ });
+ }
+ };
+}
+
+export default function createSentryMiddleware() {
+ const {
+ // analytics,
+ branch,
+ version,
+ release,
+ isProduction
+ } = window.Radarr;
+
+ // TODO update with Radarr Sentry dsn if used.
+ // if (!analytics) {
+ if (true) {
+ return;
+ }
+
+ const dsn = isProduction ? 'https://b80ca60625b443c38b242e0d21681eb7@sentry.sonarr.tv/13' :
+ 'https://8dbaacdfe2ff4caf97dc7945aecf9ace@sentry.sonarr.tv/12';
+
+ sentry.init({
+ dsn,
+ environment: isProduction ? 'production' : 'development',
+ release,
+ sendDefaultPii: true,
+ beforeSend: cleanseData
+ });
+
+ sentry.configureScope((scope) => {
+ scope.setTag('branch', branch);
+ scope.setTag('version', version);
+ });
+
+ return createMiddleware();
+}
diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js
new file mode 100644
index 000000000..59937bc45
--- /dev/null
+++ b/frontend/src/Store/Middleware/middlewares.js
@@ -0,0 +1,25 @@
+import { applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import { routerMiddleware } from 'react-router-redux';
+import createSentryMiddleware from './createSentryMiddleware';
+import createPersistState from './createPersistState';
+
+export default function(history) {
+ const middlewares = [];
+ const sentryMiddleware = createSentryMiddleware();
+
+ if (sentryMiddleware) {
+ middlewares.push(sentryMiddleware);
+ }
+
+ middlewares.push(routerMiddleware(history));
+ middlewares.push(thunk);
+
+ // eslint-disable-next-line no-underscore-dangle
+ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+
+ return composeEnhancers(
+ applyMiddleware(...middlewares),
+ createPersistState()
+ );
+}
diff --git a/frontend/src/Store/Selectors/createAllMoviesSelector.js b/frontend/src/Store/Selectors/createAllMoviesSelector.js
new file mode 100644
index 000000000..a6bad0991
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAllMoviesSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createAllMoviesSelector() {
+ return createSelector(
+ (state) => state.movies,
+ (movies) => {
+ return movies.items;
+ }
+ );
+}
+
+export default createAllMoviesSelector;
diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
new file mode 100644
index 000000000..36f9d4a56
--- /dev/null
+++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
@@ -0,0 +1,137 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
+import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
+
+function getSortClause(sortKey, sortDirection, sortPredicates) {
+ if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) {
+ return function(item) {
+ return sortPredicates[sortKey](item, sortDirection);
+ };
+ }
+
+ return function(item) {
+ return item[sortKey];
+ };
+}
+
+function filter(items, state) {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ filterPredicates
+ } = state;
+
+ if (!selectedFilterKey) {
+ return items;
+ }
+
+ const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
+
+ return _.filter(items, (item) => {
+ let i = 0;
+ let accepted = true;
+
+ while (accepted && i < selectedFilters.length) {
+ const {
+ key,
+ value,
+ type = filterTypes.EQUAL
+ } = selectedFilters[i];
+
+ if (filterPredicates && filterPredicates.hasOwnProperty(key)) {
+ const predicate = filterPredicates[key];
+
+ if (Array.isArray(value)) {
+ accepted = value.some((v) => predicate(item, v, type));
+ } else {
+ accepted = predicate(item, value, type);
+ }
+ } else if (item.hasOwnProperty(key)) {
+ const predicate = filterTypePredicates[type];
+
+ if (Array.isArray(value)) {
+ if (
+ type === filterTypes.NOT_CONTAINS ||
+ type === filterTypes.NOT_EQUAL
+ ) {
+ accepted = value.every((v) => predicate(item[key], v));
+ } else {
+ accepted = value.some((v) => predicate(item[key], v));
+ }
+ } else {
+ accepted = predicate(item[key], value);
+ }
+ } else {
+ // Default to false if the filter can't be tested
+ accepted = false;
+ }
+
+ i++;
+ }
+
+ return accepted;
+ });
+}
+
+function sort(items, state) {
+ const {
+ sortKey,
+ sortDirection,
+ sortPredicates,
+ secondarySortKey,
+ secondarySortDirection
+ } = state;
+
+ const clauses = [];
+ const orders = [];
+
+ clauses.push(getSortClause(sortKey, sortDirection, sortPredicates));
+ orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+
+ if (secondarySortKey &&
+ secondarySortDirection &&
+ (sortKey !== secondarySortKey ||
+ sortDirection !== secondarySortDirection)) {
+ clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates));
+ orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+ }
+
+ return _.orderBy(items, clauses, orders);
+}
+
+function createCustomFiltersSelector(type, alternateType) {
+ return createSelector(
+ (state) => state.customFilters.items,
+ (customFilters) => {
+ return customFilters.filter((customFilter) => {
+ return customFilter.type === type || customFilter.type === alternateType;
+ });
+ }
+ );
+}
+
+function createClientSideCollectionSelector(section, uiSection) {
+ return createSelector(
+ (state) => _.get(state, section),
+ (state) => _.get(state, uiSection),
+ createCustomFiltersSelector(section, uiSection),
+ (sectionState, uiSectionState = {}, customFilters) => {
+ const state = Object.assign({}, sectionState, uiSectionState, { customFilters });
+
+ const filtered = filter(state.items, state);
+ const sorted = sort(filtered, state);
+
+ return {
+ ...sectionState,
+ ...uiSectionState,
+ customFilters,
+ items: sorted,
+ totalItems: state.items.length
+ };
+ }
+ );
+}
+
+export default createClientSideCollectionSelector;
diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js
new file mode 100644
index 000000000..6037d5820
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+import createCommandSelector from './createCommandSelector';
+
+function createCommandExecutingSelector(name, contraints = {}) {
+ return createSelector(
+ createCommandSelector(name, contraints),
+ (command) => {
+ return isCommandExecuting(command);
+ }
+ );
+}
+
+export default createCommandExecutingSelector;
diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js
new file mode 100644
index 000000000..709dfebaf
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandSelector.js
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import { findCommand } from 'Utilities/Command';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandSelector(name, contraints = {}) {
+ return createSelector(
+ createCommandsSelector(),
+ (commands) => {
+ return findCommand(commands, { name, ...contraints });
+ }
+ );
+}
+
+export default createCommandSelector;
diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.js
new file mode 100644
index 000000000..7b9edffd9
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createCommandsSelector() {
+ return createSelector(
+ (state) => state.commands,
+ (commands) => {
+ return commands.items;
+ }
+ );
+}
+
+export default createCommandsSelector;
diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.js
new file mode 100644
index 000000000..ce26b2e2c
--- /dev/null
+++ b/frontend/src/Store/Selectors/createDimensionsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createDimensionsSelector() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ (dimensions) => {
+ return dimensions;
+ }
+ );
+}
+
+export default createDimensionsSelector;
diff --git a/frontend/src/Store/Selectors/createExistingMovieSelector.js b/frontend/src/Store/Selectors/createExistingMovieSelector.js
new file mode 100644
index 000000000..94bb63687
--- /dev/null
+++ b/frontend/src/Store/Selectors/createExistingMovieSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from './createAllMoviesSelector';
+
+function createExistingMovieSelector() {
+ return createSelector(
+ (state, { tmdbId }) => tmdbId,
+ createAllMoviesSelector(),
+ (tmdbId, movies) => {
+ return _.some(movies, { tmdbId });
+ }
+ );
+}
+
+export default createExistingMovieSelector;
diff --git a/frontend/src/Store/Selectors/createImportMovieItemSelector.js b/frontend/src/Store/Selectors/createImportMovieItemSelector.js
new file mode 100644
index 000000000..02fa2558f
--- /dev/null
+++ b/frontend/src/Store/Selectors/createImportMovieItemSelector.js
@@ -0,0 +1,26 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from './createAllMoviesSelector';
+
+function createImportMovieItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.addMovie,
+ (state) => state.importMovie,
+ createAllMoviesSelector(),
+ (id, addMovie, importMovie, series) => {
+ const item = _.find(importMovie.items, { id }) || {};
+ const selectedMovie = item && item.selectedMovie;
+ const isExistingMovie = !!selectedMovie && _.some(series, { tvdbId: selectedMovie.tvdbId });
+
+ return {
+ defaultMonitor: addMovie.defaults.monitor,
+ defaultQualityProfileId: addMovie.defaults.qualityProfileId,
+ ...item,
+ isExistingMovie
+ };
+ }
+ );
+}
+
+export default createImportMovieItemSelector;
diff --git a/frontend/src/Store/Selectors/createMovieCountSelector.js b/frontend/src/Store/Selectors/createMovieCountSelector.js
new file mode 100644
index 000000000..e8e76eaa4
--- /dev/null
+++ b/frontend/src/Store/Selectors/createMovieCountSelector.js
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from './createAllMoviesSelector';
+
+function createMovieCountSelector() {
+ return createSelector(
+ createAllMoviesSelector(),
+ (movies) => {
+ return movies.length;
+ }
+ );
+}
+
+export default createMovieCountSelector;
diff --git a/frontend/src/Store/Selectors/createMovieFileSelector.js b/frontend/src/Store/Selectors/createMovieFileSelector.js
new file mode 100644
index 000000000..d9b4230ad
--- /dev/null
+++ b/frontend/src/Store/Selectors/createMovieFileSelector.js
@@ -0,0 +1,17 @@
+import { createSelector } from 'reselect';
+
+function createMovieFileSelector() {
+ return createSelector(
+ (state, { movieFileId }) => movieFileId,
+ (state) => state.movieFiles,
+ (movieFileId, movieFiles) => {
+ if (!movieFileId) {
+ return;
+ }
+
+ return movieFiles.items.find((movieFile) => movieFile.id === movieFileId);
+ }
+ );
+}
+
+export default createMovieFileSelector;
diff --git a/frontend/src/Store/Selectors/createMovieSelector.js b/frontend/src/Store/Selectors/createMovieSelector.js
new file mode 100644
index 000000000..306547cf7
--- /dev/null
+++ b/frontend/src/Store/Selectors/createMovieSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from './createAllMoviesSelector';
+
+function createMovieSelector() {
+ return createSelector(
+ (state, { movieId }) => movieId,
+ createAllMoviesSelector(),
+ (movieId, movies) => {
+ return _.find(movies, { id: movieId });
+ }
+ );
+}
+
+export default createMovieSelector;
diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js
new file mode 100644
index 000000000..baa6853e0
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js
@@ -0,0 +1,19 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllMoviesSelector from './createAllMoviesSelector';
+
+function createProfileInUseSelector(profileProp) {
+ return createSelector(
+ (state, { id }) => id,
+ createAllMoviesSelector(),
+ (id, series) => {
+ if (!id) {
+ return false;
+ }
+
+ return _.some(series, { [profileProp]: id });
+ }
+ );
+}
+
+export default createProfileInUseSelector;
diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
new file mode 100644
index 000000000..46659609f
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
@@ -0,0 +1,63 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createProviderSettingsSelector(sectionName) {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings[sectionName],
+ (id, section) => {
+ if (!id) {
+ const item = _.isArray(section.schema) ? section.selectedSchema : section.schema;
+ const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError);
+
+ const {
+ isSchemaFetching: isFetching,
+ isSchemaPopulated: isPopulated,
+ schemaError: error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges,
+ ...settings,
+ item: settings.settings
+ };
+ }
+
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ ...settings,
+ item: settings.settings
+ };
+ }
+ );
+}
+
+export default createProviderSettingsSelector;
diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js
new file mode 100644
index 000000000..9308d63ac
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createQualityProfileSelector() {
+ return createSelector(
+ (state, { qualityProfileId }) => qualityProfileId,
+ (state) => state.settings.qualityProfiles.items,
+ (qualityProfileId, qualityProfiles) => {
+ return _.find(qualityProfiles, { id: qualityProfileId });
+ }
+ );
+}
+
+export default createQualityProfileSelector;
diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js
new file mode 100644
index 000000000..f2ff2b907
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQueueItemSelector.js
@@ -0,0 +1,23 @@
+import { createSelector } from 'reselect';
+
+function createQueueItemSelector() {
+ return createSelector(
+ (state, { episodeId }) => episodeId,
+ (state) => state.queue.details.items,
+ (episodeId, details) => {
+ if (!episodeId) {
+ return null;
+ }
+
+ return details.find((item) => {
+ if (item.episode) {
+ return item.episode.id === episodeId;
+ }
+
+ return false;
+ });
+ }
+ );
+}
+
+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..a9f6cbff6
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
@@ -0,0 +1,32 @@
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createSettingsSectionSelector(section) {
+ return createSelector(
+ (state) => state.settings[section],
+ (sectionSettings) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ item,
+ pendingChanges,
+ isSaving,
+ saveError
+ } = sectionSettings;
+
+ const settings = selectSettings(item, pendingChanges, saveError);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ ...settings
+ };
+ }
+ );
+}
+
+export default createSettingsSectionSelector;
diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.js
new file mode 100644
index 000000000..df586bbb9
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSystemStatusSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createSystemStatusSelector() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return status.item;
+ }
+ );
+}
+
+export default createSystemStatusSelector;
diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.js
new file mode 100644
index 000000000..dd178944c
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTagDetailsSelector.js
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+
+function createTagDetailsSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.tags.details.items,
+ (id, tagDetails) => {
+ return tagDetails.find((t) => t.id === id);
+ }
+ );
+}
+
+export default createTagDetailsSelector;
diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.js
new file mode 100644
index 000000000..fbfd91cdb
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTagsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createTagsSelector() {
+ return createSelector(
+ (state) => state.tags.items,
+ (tags) => {
+ return tags;
+ }
+ );
+}
+
+export default createTagsSelector;
diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.js
new file mode 100644
index 000000000..b256d0e98
--- /dev/null
+++ b/frontend/src/Store/Selectors/createUISettingsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createUISettingsSelector() {
+ return createSelector(
+ (state) => state.settings.ui,
+ (ui) => {
+ return ui.item;
+ }
+ );
+}
+
+export default createUISettingsSelector;
diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js
new file mode 100644
index 000000000..3e30478b7
--- /dev/null
+++ b/frontend/src/Store/Selectors/selectSettings.js
@@ -0,0 +1,104 @@
+import _ from 'lodash';
+
+function getValidationFailures(saveError) {
+ if (!saveError || saveError.status !== 400) {
+ return [];
+ }
+
+ return _.cloneDeep(saveError.responseJSON);
+}
+
+function mapFailure(failure) {
+ return {
+ message: failure.errorMessage,
+ link: failure.infoLink,
+ detailedMessage: failure.detailedDescription
+ };
+}
+
+function selectSettings(item, pendingChanges, saveError) {
+ const validationFailures = getValidationFailures(saveError);
+
+ // Merge all settings from the item along with pending
+ // changes to ensure any settings that were not included
+ // with the item are included.
+ const allSettings = Object.assign({}, item, pendingChanges);
+
+ const settings = _.reduce(allSettings, (result, value, key) => {
+ if (key === 'fields') {
+ return result;
+ }
+
+ // Return a flattened value
+ if (key === 'implementationName') {
+ result.implementationName = item[key];
+
+ return result;
+ }
+
+ const setting = {
+ value: item[key],
+ errors: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning;
+ }), mapFailure),
+
+ warnings: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning;
+ }), mapFailure)
+ };
+
+ if (pendingChanges.hasOwnProperty(key)) {
+ setting.previousValue = setting.value;
+ setting.value = pendingChanges[key];
+ setting.pending = true;
+ }
+
+ result[key] = setting;
+ return result;
+ }, {});
+
+ const fields = _.reduce(item.fields, (result, f) => {
+ const field = Object.assign({ pending: false }, f);
+ const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name);
+
+ if (hasPendingFieldChange) {
+ field.previousValue = field.value;
+ field.value = pendingChanges.fields[field.name];
+ field.pending = true;
+ }
+
+ field.errors = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning;
+ }), mapFailure);
+
+ field.warnings = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning;
+ }), mapFailure);
+
+ result.push(field);
+ return result;
+ }, []);
+
+ if (fields.length) {
+ settings.fields = fields;
+ }
+
+ const validationErrors = _.filter(validationFailures, (failure) => {
+ return !failure.isWarning;
+ });
+
+ const validationWarnings = _.filter(validationFailures, (failure) => {
+ return failure.isWarning;
+ });
+
+ return {
+ settings,
+ validationErrors,
+ validationWarnings,
+ hasPendingChanges: !_.isEmpty(pendingChanges),
+ hasSettings: !_.isEmpty(settings),
+ pendingChanges
+ };
+}
+
+export default selectSettings;
diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js
new file mode 100644
index 000000000..e05c80323
--- /dev/null
+++ b/frontend/src/Store/createAppStore.js
@@ -0,0 +1,15 @@
+import { createStore } from 'redux';
+import reducers, { defaultState } from 'Store/Actions/reducers';
+import middlewares from 'Store/Middleware/middlewares';
+
+function createAppStore(history) {
+ const appStore = createStore(
+ reducers,
+ defaultState,
+ middlewares(history)
+ );
+
+ return appStore;
+}
+
+export default createAppStore;
diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js
new file mode 100644
index 000000000..4f926b7c0
--- /dev/null
+++ b/frontend/src/Store/scrollPositions.js
@@ -0,0 +1,5 @@
+const scrollPositions = {
+ movieIndex: 0
+};
+
+export default scrollPositions;
diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js
new file mode 100644
index 000000000..ebcf10917
--- /dev/null
+++ b/frontend/src/Store/thunks.js
@@ -0,0 +1,28 @@
+const thunks = {};
+
+function identity(payload) {
+ return payload;
+}
+
+export function createThunk(type, identityFunction = identity) {
+ return function(payload = {}) {
+ return function(dispatch, getState) {
+ const thunk = thunks[type];
+
+ if (thunk) {
+ return thunk(getState, identityFunction(payload), dispatch);
+ }
+
+ throw Error(`Thunk handler has not been registered for ${type}`);
+ };
+ };
+}
+
+export function handleThunks(handlers) {
+ const types = Object.keys(handlers);
+
+ types.forEach((type) => {
+ thunks[type] = handlers[type];
+ });
+}
+
diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css
new file mode 100644
index 000000000..e44c99be6
--- /dev/null
+++ b/frontend/src/Styles/Mixins/cover.css
@@ -0,0 +1,8 @@
+@define-mixin cover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+}
diff --git a/frontend/src/Styles/Mixins/linkOverlay.css b/frontend/src/Styles/Mixins/linkOverlay.css
new file mode 100644
index 000000000..74c3fd753
--- /dev/null
+++ b/frontend/src/Styles/Mixins/linkOverlay.css
@@ -0,0 +1,11 @@
+@define-mixin linkOverlay {
+ @add-mixin cover;
+
+ pointer-events: none;
+ user-select: none;
+
+ a,
+ button {
+ pointer-events: all;
+ }
+}
diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css
new file mode 100644
index 000000000..62a619103
--- /dev/null
+++ b/frontend/src/Styles/Mixins/scroller.css
@@ -0,0 +1,26 @@
+@define-mixin scrollbar {
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+}
+
+@define-mixin scrollbarTrack {
+ &&::-webkit-scrollbar-track {
+ background-color: transparent;
+ }
+}
+
+@define-mixin scrollbarThumb {
+ &::-webkit-scrollbar-thumb {
+ min-height: 50px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background-color: $scrollbarBackgroundColor;
+ background-clip: padding-box;
+
+ &:hover {
+ background-color: $scrollbarHoverBackgroundColor;
+ }
+ }
+}
diff --git a/frontend/src/Styles/Mixins/truncate.css b/frontend/src/Styles/Mixins/truncate.css
new file mode 100644
index 000000000..1941afc9b
--- /dev/null
+++ b/frontend/src/Styles/Mixins/truncate.css
@@ -0,0 +1,18 @@
+/**
+ * From: https://github.com/suitcss/utils-text/blob/master/lib/text.css
+ *
+ * Text truncation
+ *
+ * Prevent text from wrapping onto multiple lines, and truncate with an
+ * ellipsis.
+ *
+ * 1. Ensure that the node has a maximum width after which truncation can
+ * occur.
+ */
+
+@define-mixin truncate {
+ overflow: hidden !important;
+ max-width: 100%; /* 1 */
+ text-overflow: ellipsis !important;
+ white-space: nowrap !important;
+}
diff --git a/frontend/src/Styles/Variables/animations.js b/frontend/src/Styles/Variables/animations.js
new file mode 100644
index 000000000..52d12827a
--- /dev/null
+++ b/frontend/src/Styles/Variables/animations.js
@@ -0,0 +1,8 @@
+// Use CommonJS since this is consumed by PostCSS via webpack (node.js).
+
+module.exports = {
+ // Durations
+ defaultSpeed: '0.2s',
+ slowSpeed: '0.6s',
+ fastSpeed: '0.1s'
+};
diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js
new file mode 100644
index 000000000..8a316dcd5
--- /dev/null
+++ b/frontend/src/Styles/Variables/colors.js
@@ -0,0 +1,180 @@
+const radarrYellow = '#ffc230';
+
+module.exports = {
+ defaultColor: '#333',
+ disabledColor: '#999',
+ dimColor: '#555',
+ black: '#000',
+ white: '#fff',
+ offWhite: '#f5f7fa',
+ primaryColor: '#5d9cec',
+ selectedColor: '#f9be03',
+ successColor: '#27c24c',
+ dangerColor: '#f05050',
+ warningColor: '#ffa500',
+ infoColor: '#5d9cec',
+ purple: '#7a43b6',
+ pink: '#ff69b4',
+ radarrYellow,
+ helpTextColor: '#909293',
+ darkGray: '#888',
+ gray: '#adadad',
+ lightGray: '#ddd',
+ disabledInputColor: '#808080',
+
+ // Theme Colors
+
+ themeBlue: radarrYellow,
+ themeRed: '#c4273c',
+ themeDarkColor: '#595959',
+ themeLightColor: '#707070',
+
+ torrentColor: '#00853d',
+ usenetColor: '#17b1d9',
+
+ // Links
+ defaultLinkHoverColor: '#fff',
+ linkColor: '#5d9cec',
+ linkHoverColor: '#1b72e2',
+
+ // Sidebar
+
+ sidebarColor: '#e1e2e3',
+ sidebarBackgroundColor: '#595959',
+ sidebarActiveBackgroundColor: '#333333',
+
+ // Toolbar
+ toolbarColor: '#e1e2e3',
+ toolbarBackgroundColor: '#707070',
+ toolbarMenuItemBackgroundColor: '#606060',
+ toolbarMenuItemHoverBackgroundColor: '#515151',
+ toolbarLabelColor: '#e1e2e3',
+
+ // Accents
+ borderColor: '#e5e5e5',
+ inputBorderColor: '#dde6e9',
+ inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
+ inputFocusBorderColor: '#66afe9',
+ inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
+ inputErrorBorderColor: '#f05050',
+ inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)',
+ inputWarningBorderColor: '#ffa500',
+ inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)',
+ colorImpairedGradient: '#fcfcfc',
+
+ //
+ // Buttons
+
+ defaultBackgroundColor: '#fff',
+ defaultBorderColor: '#eaeaea',
+ defaultHoverBackgroundColor: '#f5f5f5',
+ defaultHoverBorderColor: '#d6d6d6;',
+
+ primaryBackgroundColor: '#5d9cec',
+ primaryBorderColor: '#5899eb',
+ primaryHoverBackgroundColor: '#4b91ea',
+ primaryHoverBorderColor: '#3483e7;',
+
+ successBackgroundColor: '#27c24c',
+ successBorderColor: '#26be4a',
+ successHoverBackgroundColor: '#24b145',
+ successHoverBorderColor: '#1f9c3d;',
+
+ warningBackgroundColor: '#ff902b',
+ warningBorderColor: '#ff8d26',
+ warningHoverBackgroundColor: '#ff8517',
+ warningHoverBorderColor: '#fc7800;',
+
+ dangerBackgroundColor: '#f05050',
+ dangerBorderColor: '#f04b4b',
+ dangerHoverBackgroundColor: '#ee3d3d',
+ dangerHoverBorderColor: '#ec2626;',
+
+ iconButtonDisabledColor: '#7a7a7a',
+ iconButtonHoverColor: '#666',
+ iconButtonHoverLightColor: '#ccc',
+
+ //
+ // Modal
+
+ modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)',
+ modalBackgroundColor: '#fff',
+ modalCloseButtonHoverColor: '#888',
+
+ //
+ // Menu
+ menuItemColor: '#e1e2e3',
+ menuItemHoverColor: '#fbfcfc',
+ menuItemHoverBackgroundColor: '#f5f7fa',
+
+ //
+ // Toolbar
+
+ toobarButtonHoverColor: '#ffc230',
+ toobarButtonSelectedColor: '#ffc230',
+
+ //
+ // Scroller
+
+ scrollbarBackgroundColor: '#9ea4b9',
+ scrollbarHoverBackgroundColor: '#656d8c',
+
+ //
+ // Card
+
+ cardShadowColor: '#e1e1e1',
+ cardAlternateBackgroundColor: '#f5f5f5',
+
+ //
+ // Alert
+
+ alertDangerBorderColor: '#ebccd1',
+ alertDangerBackgroundColor: '#f2dede',
+ alertDangerColor: '#a94442',
+
+ alertInfoBorderColor: '#bce8f1',
+ alertInfoBackgroundColor: '#d9edf7',
+ alertInfoColor: '#31708f',
+
+ alertSuccessBorderColor: '#d6e9c6',
+ alertSuccessBackgroundColor: '#dff0d8',
+ alertSuccessColor: '#3c763d',
+
+ alertWarningBorderColor: '#faebcc',
+ alertWarningBackgroundColor: '#fcf8e3',
+ alertWarningColor: '#8a6d3b',
+
+ //
+ // Slider
+
+ sliderAccentColor: '#5d9cec',
+
+ //
+ // Form
+
+ advancedFormLabelColor: '#ff902b',
+ disabledCheckInputColor: '#ddd',
+
+ //
+ // Popover
+
+ popoverTitleBackgroundColor: '#f7f7f7',
+ popoverTitleBorderColor: '#ebebeb',
+ popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)',
+
+ popoverTitleBackgroundInverseColor: '#3a3f51',
+ popoverTitleBorderInverseColor: '#4f566f',
+ popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)',
+
+ //
+ // Calendar
+
+ calendarTodayBackgroundColor: '#ddd',
+
+ //
+ // Table
+
+ tableRowHoverBackgroundColor: '#fafbfc'
+};
diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js
new file mode 100644
index 000000000..5d6a116db
--- /dev/null
+++ b/frontend/src/Styles/Variables/dimensions.js
@@ -0,0 +1,53 @@
+module.exports = {
+ // Page
+ pageContentBodyPadding: '20px',
+ pageContentBodyPaddingSmallScreen: '10px',
+
+ // Header
+ headerHeight: '60px',
+
+ // Sidebar
+ sidebarWidth: '210px',
+
+ // Toolbar
+ toolbarHeight: '60px',
+ toolbarButtonWidth: '60px',
+ toolbarSeparatorMargin: '20px',
+
+ // Break Points
+ breakpointExtraSmall: '480px',
+ breakpointSmall: '768px',
+ breakpointMedium: '992px',
+ breakpointLarge: '1200px',
+ breakpointExtraLarge: '1450px',
+
+ // Form
+ formGroupExtraSmallWidth: '550px',
+ formGroupSmallWidth: '650px',
+ formGroupMediumWidth: '800px',
+ formGroupLargeWidth: '1200px',
+ formLabelSmallWidth: '150px',
+ formLabelLargeWidth: '250px',
+ formLabelRightMarginWidth: '20px',
+
+ // Drag
+ dragHandleWidth: '40px',
+ qualityProfileItemHeight: '30px',
+ qualityProfileItemDragSourcePadding: '4px',
+
+ // Progress Bar
+ progressBarSmallHeight: '5px',
+ progressBarMediumHeight: '15px',
+ progressBarLargeHeight: '20px',
+
+ // Jump Bar
+ jumpBarItemHeight: '25px',
+
+ // Modal
+ modalBodyPadding: '30px',
+
+ // Series
+ movieIndexColumnPadding: '20px',
+ movieIndexColumnPaddingSmallScreen: '10px',
+ movieIndexOverviewInfoRowHeight: '21px'
+};
diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js
new file mode 100644
index 000000000..3b0077c5a
--- /dev/null
+++ b/frontend/src/Styles/Variables/fonts.js
@@ -0,0 +1,15 @@
+module.exports = {
+ // Families
+ defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
+ monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
+ passwordFamily: 'text-security-disc',
+
+ // Sizes
+ extraSmallFontSize: '11px',
+ smallFontSize: '12px',
+ defaultFontSize: '14px',
+ intermediateFontSize: '15px',
+ largeFontSize: '16px',
+
+ lineHeight: '1.528571429'
+};
diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css
new file mode 100644
index 000000000..70967f0c4
--- /dev/null
+++ b/frontend/src/Styles/globals.css
@@ -0,0 +1,7 @@
+/* stylelint-disable */
+
+@import '~normalize.css/normalize.css';
+@import 'scaffolding.css';
+@import '../Content/Fonts/fonts.css';
+
+/* stylelint-enable */
diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css
new file mode 100644
index 000000000..1810037cf
--- /dev/null
+++ b/frontend/src/Styles/scaffolding.css
@@ -0,0 +1,54 @@
+/* stylelint-disable */
+* {
+ box-sizing: border-box;
+}
+
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+*:focus {
+ outline: none;
+}
+/* stylelint-enable */
+
+html,
+body {
+ color: #515253;
+ font-family: 'Roboto', 'open sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
+}
+
+body {
+ font-size: 14px;
+ line-height: 1.528571429; /* 20/14 */
+}
+
+/* Override normalize */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ margin: 0;
+ font-size: inherit;
+ font-family: inherit;
+ line-height: 1.528571429; /* 20/14 */
+}
+
+/* Better defaults for unordererd lists */
+
+ul {
+ margin: 0;
+ padding-left: 20px;
+}
+
+@media only screen and (min-device-width: 375px) and (max-device-width: 812px) {
+ input,
+ optgroup,
+ select,
+ textarea {
+ font-size: 16px;
+ }
+}
diff --git a/frontend/src/System/Backup/BackupRow.css b/frontend/src/System/Backup/BackupRow.css
new file mode 100644
index 000000000..d83a22e25
--- /dev/null
+++ b/frontend/src/System/Backup/BackupRow.css
@@ -0,0 +1,12 @@
+.type {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+ text-align: center;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js
new file mode 100644
index 000000000..df5ff974c
--- /dev/null
+++ b/frontend/src/System/Backup/BackupRow.js
@@ -0,0 +1,153 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import RestoreBackupModalConnector from './RestoreBackupModalConnector';
+import styles from './BackupRow.css';
+
+class BackupRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestoreModalOpen: false,
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRestorePress = () => {
+ this.setState({ isRestoreModalOpen: true });
+ }
+
+ onRestoreModalClose = () => {
+ this.setState({ isRestoreModalOpen: false });
+ }
+
+ onDeletePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ }
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ }
+
+ onConfirmDeletePress = () => {
+ const {
+ id,
+ onDeleteBackupPress
+ } = this.props;
+
+ this.setState({ isConfirmDeleteModalOpen: false }, () => {
+ onDeleteBackupPress(id);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ type,
+ name,
+ path,
+ time
+ } = this.props;
+
+ const {
+ isRestoreModalOpen,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ let iconClassName = icons.SCHEDULED;
+ let iconTooltip = 'Scheduled';
+
+ if (type === 'manual') {
+ iconClassName = icons.INTERACTIVE;
+ iconTooltip = 'Manual';
+ } else if (type === 'update') {
+ iconClassName = icons.UPDATE;
+ iconTooltip = 'Before update';
+ }
+
+ return (
+
+
+ {
+
+ }
+
+
+
+
+ {name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+BackupRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ type: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ onDeleteBackupPress: PropTypes.func.isRequired
+};
+
+export default BackupRow;
diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js
new file mode 100644
index 000000000..97167e6f2
--- /dev/null
+++ b/frontend/src/System/Backup/Backups.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import BackupRow from './BackupRow';
+import RestoreBackupModalConnector from './RestoreBackupModalConnector';
+
+const columns = [
+ {
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'time',
+ label: 'Time',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+class Backups extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestoreModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRestorePress = () => {
+ this.setState({ isRestoreModalOpen: true });
+ }
+
+ onRestoreModalClose = () => {
+ this.setState({ isRestoreModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ backupExecuting,
+ onBackupPress,
+ onDeleteBackupPress
+ } = this.props;
+
+ const hasBackups = isPopulated && !!items.length;
+ const noBackups = isPopulated && !items.length;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load backups
+ }
+
+ {
+ noBackups &&
+ No backups are available
+ }
+
+ {
+ hasBackups &&
+
+
+ {
+ items.map((item) => {
+ const {
+ id,
+ type,
+ name,
+ path,
+ time
+ } = item;
+
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+ );
+ }
+
+}
+
+Backups.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ backupExecuting: PropTypes.bool.isRequired,
+ onBackupPress: PropTypes.func.isRequired,
+ onDeleteBackupPress: PropTypes.func.isRequired
+};
+
+export default Backups;
diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js
new file mode 100644
index 000000000..434354f5b
--- /dev/null
+++ b/frontend/src/System/Backup/BackupsConnector.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import Backups from './Backups';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.backups,
+ createCommandExecutingSelector(commandNames.BACKUP),
+ (backups, backupExecuting) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = backups;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ backupExecuting
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchBackups() {
+ dispatch(fetchBackups());
+ },
+
+ onDeleteBackupPress(id) {
+ dispatch(deleteBackup({ id }));
+ },
+
+ onBackupPress() {
+ dispatch(executeCommand({
+ name: commandNames.BACKUP
+ }));
+ }
+ };
+}
+
+class BackupsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchBackups();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.backupExecuting && !this.props.backupExecuting) {
+ this.props.dispatchFetchBackups();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BackupsConnector.propTypes = {
+ backupExecuting: PropTypes.bool.isRequired,
+ dispatchFetchBackups: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector);
diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js
new file mode 100644
index 000000000..48dad4d2a
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector';
+
+function RestoreBackupModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+RestoreBackupModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RestoreBackupModal;
diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js
new file mode 100644
index 000000000..98cbcd11b
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalConnector.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { clearRestoreBackup } from 'Store/Actions/systemActions';
+import RestoreBackupModal from './RestoreBackupModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ dispatch(clearRestoreBackup());
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(RestoreBackupModal);
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css
new file mode 100644
index 000000000..783443e33
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.css
@@ -0,0 +1,24 @@
+.additionalInfo {
+ flex-grow: 1;
+ color: #777;
+}
+
+.steps {
+ margin-top: 20px;
+}
+
+.step {
+ display: flex;
+ font-size: $largeFontSize;
+ line-height: 20px;
+}
+
+.stepState {
+ margin-right: 8px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ composes: modalFooter from 'Components/Modal/ModalFooter.css';
+
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js
new file mode 100644
index 000000000..274e657e5
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.js
@@ -0,0 +1,232 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TextInput from 'Components/Form/TextInput';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './RestoreBackupModalContent.css';
+
+function getErrorMessage(error) {
+ if (!error || !error.responseJSON || !error.responseJSON.message) {
+ return 'Error restoring backup';
+ }
+
+ return error.responseJSON.message;
+}
+
+function getStepIconProps(isExecuting, hasExecuted, error) {
+ if (isExecuting) {
+ return {
+ name: icons.SPINNER,
+ isSpinning: true
+ };
+ }
+
+ if (hasExecuted) {
+ return {
+ name: icons.CHECK,
+ kind: kinds.SUCCESS
+ };
+ }
+
+ if (error) {
+ return {
+ name: icons.FATAL,
+ kinds: kinds.DANGER,
+ title: getErrorMessage(error)
+ };
+ }
+
+ return {
+ name: icons.PENDING
+ };
+}
+
+class RestoreBackupModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ file: null,
+ path: '',
+ isRestored: false,
+ isRestarted: false,
+ isReloading: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isRestoring,
+ restoreError,
+ isRestarting,
+ dispatchRestart
+ } = this.props;
+
+ if (prevProps.isRestoring && !isRestoring && !restoreError) {
+ this.setState({ isRestored: true }, () => {
+ dispatchRestart();
+ });
+ }
+
+ if (prevProps.isRestarting && !isRestarting) {
+ this.setState({
+ isRestarted: true,
+ isReloading: true
+ }, () => {
+ location.reload();
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onPathChange = ({ value, files }) => {
+ this.setState({
+ file: files[0],
+ path: value
+ });
+ }
+
+ onRestorePress = () => {
+ const {
+ id,
+ onRestorePress
+ } = this.props;
+
+ onRestorePress({
+ id,
+ file: this.state.file
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ isRestoring,
+ restoreError,
+ isRestarting,
+ onModalClose
+ } = this.props;
+
+ const {
+ path,
+ isRestored,
+ isRestarted,
+ isReloading
+ } = this.state;
+
+ const isRestoreDisabled = (
+ (!id && !path) ||
+ isRestoring ||
+ isRestarting ||
+ isReloading
+ );
+
+ return (
+
+
+ Restore Backup
+
+
+
+ {
+ !!id && `Would you like to restore the backup '${name}'?`
+ }
+
+ {
+ !id &&
+
+ }
+
+
+
+
+
+
+ Note: Radarr will automatically restart and reload the UI during the restore process.
+
+
+
+ Cancel
+
+
+
+ Restore
+
+
+
+ );
+ }
+}
+
+RestoreBackupModalContent.propTypes = {
+ id: PropTypes.number,
+ name: PropTypes.string,
+ path: PropTypes.string,
+ isRestoring: PropTypes.bool.isRequired,
+ restoreError: PropTypes.object,
+ isRestarting: PropTypes.bool.isRequired,
+ dispatchRestart: PropTypes.func.isRequired,
+ onRestorePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RestoreBackupModalContent;
diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js
new file mode 100644
index 000000000..7f2b7a6e8
--- /dev/null
+++ b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js
@@ -0,0 +1,37 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { restoreBackup, restart } from 'Store/Actions/systemActions';
+import RestoreBackupModalContent from './RestoreBackupModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.backups,
+ (state) => state.app.isRestarting,
+ (backups, isRestarting) => {
+ const {
+ isRestoring,
+ restoreError
+ } = backups;
+
+ return {
+ isRestoring,
+ restoreError,
+ isRestarting
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onRestorePress(payload) {
+ dispatch(restoreBackup(payload));
+ },
+
+ dispatchRestart() {
+ dispatch(restart());
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent);
diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js
new file mode 100644
index 000000000..ce6d0c995
--- /dev/null
+++ b/frontend/src/System/Events/LogsTable.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, icons } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import LogsTableRow from './LogsTableRow';
+
+function LogsTable(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ selectedFilterKey,
+ filters,
+ totalRecords,
+ clearLogExecuting,
+ onRefreshPress,
+ onClearLogsPress,
+ onFilterSelect,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No logs found
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+}
+
+LogsTable.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ clearLogExecuting: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onClearLogsPress: PropTypes.func.isRequired
+};
+
+export default LogsTable;
diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js
new file mode 100644
index 000000000..d2cb6caf8
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableConnector.js
@@ -0,0 +1,141 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as systemActions from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogsTable from './LogsTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logs,
+ createCommandExecutingSelector(commandNames.CLEAR_LOGS),
+ (logs, clearLogExecuting) => {
+ return {
+ clearLogExecuting,
+ ...logs
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand,
+ ...systemActions
+};
+
+class LogsTableConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchLogs,
+ gotoLogsFirstPage
+ } = this.props;
+
+ if (useCurrentPage) {
+ fetchLogs();
+ } else {
+ gotoLogsFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) {
+ this.props.gotoLogsFirstPage();
+ }
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoLogsFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoLogsPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoLogsNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoLogsLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoLogsPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setLogsSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setLogsFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setLogsTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoLogsFirstPage();
+ }
+ }
+
+ onRefreshPress = () => {
+ this.props.gotoLogsFirstPage();
+ }
+
+ onClearLogsPress = () => {
+ this.props.executeCommand({ name: commandNames.CLEAR_LOGS });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogsTableConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ 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 withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector)
+);
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css
new file mode 100644
index 000000000..127c1139f
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.css
@@ -0,0 +1,17 @@
+.detailsText {
+ composes: scroller from 'Components/Scroller/Scroller.css';
+
+ display: block;
+ margin: 0 0 10.5px;
+ padding: 10px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background-color: #f5f5f5;
+ color: #3a3f51;
+ white-space: pre;
+ word-wrap: break-word;
+ word-break: break-all;
+ font-size: 13px;
+ font-family: $monoSpaceFontFamily;
+ line-height: 1.52857143;
+}
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js
new file mode 100644
index 000000000..de6a881df
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.js
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Scroller from 'Components/Scroller/Scroller';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './LogsTableDetailsModal.css';
+
+function LogsTableDetailsModal(props) {
+ const {
+ isOpen,
+ message,
+ exception,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ Details
+
+
+
+ Message
+
+
+ {message}
+
+
+ {
+ !!exception &&
+
+
Exception
+
+ {exception}
+
+
+ }
+
+
+
+
+ Close
+
+
+
+
+ );
+}
+
+LogsTableDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ message: PropTypes.string.isRequired,
+ exception: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default LogsTableDetailsModal;
diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css
new file mode 100644
index 000000000..557d690f0
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.css
@@ -0,0 +1,35 @@
+.level {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
+
+.info {
+ color: #1e90ff;
+}
+
+.debug {
+ color: #808080;
+}
+
+.trace {
+ color: #d3d3d3;
+}
+
+.warn {
+ color: $warningColor;
+}
+
+.error {
+ color: $dangerColor;
+}
+
+.fatal {
+ color: $purple;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 45px;
+}
diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js
new file mode 100644
index 000000000..390769727
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.js
@@ -0,0 +1,158 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import LogsTableDetailsModal from './LogsTableDetailsModal';
+import styles from './LogsTableRow.css';
+
+function getIconName(level) {
+ switch (level) {
+ case 'trace':
+ case 'debug':
+ case 'info':
+ return icons.INFO;
+ case 'warn':
+ return icons.DANGER;
+ case 'error':
+ return icons.BUG;
+ case 'fatal':
+ return icons.FATAL;
+ default:
+ return icons.UNKNOWN;
+ }
+}
+
+class LogsTableRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ // Don't re-open the modal if it's already open
+ if (!this.state.isDetailsModalOpen) {
+ this.setState({ isDetailsModalOpen: true });
+ }
+ }
+
+ onModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ level,
+ logger,
+ message,
+ time,
+ exception,
+ columns
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'level') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'logger') {
+ return (
+
+ {logger}
+
+ );
+ }
+
+ if (name === 'message') {
+ return (
+
+ {message}
+
+ );
+ }
+
+ if (name === 'time') {
+ return (
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+LogsTableRow.propTypes = {
+ level: PropTypes.string.isRequired,
+ logger: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ exception: PropTypes.string,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default LogsTableRow;
diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js
new file mode 100644
index 000000000..47482b3fe
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFiles.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import TableBody from 'Components/Table/TableBody';
+import LogsNavMenu from '../LogsNavMenu';
+import LogFilesTableRow from './LogFilesTableRow';
+
+const columns = [
+ {
+ name: 'filename',
+ label: 'Filename',
+ isVisible: true
+ },
+ {
+ name: 'lastWriteTime',
+ label: 'Last Write Time',
+ isVisible: true
+ },
+ {
+ name: 'download',
+ isVisible: true
+ }
+];
+
+class LogFiles extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView,
+ location,
+ onRefreshPress,
+ onDeleteFilesPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Log files are located in: {location}
+
+
+ {
+ currentLogView === 'Log Files' &&
+
+ The log level defaults to 'Info' and can be changed in General Settings
+
+ }
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !isFetching && !items.length &&
+ No log files
+ }
+
+
+ );
+ }
+
+}
+
+LogFiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ currentLogView: PropTypes.string.isRequired,
+ location: PropTypes.string.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onDeleteFilesPress: PropTypes.func.isRequired
+};
+
+export default LogFiles;
diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js
new file mode 100644
index 000000000..628bb571c
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import combinePath from 'Utilities/String/combinePath';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchLogFiles } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogFiles from './LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logFiles,
+ (state) => state.system.status.item,
+ createCommandExecutingSelector(commandNames.DELETE_LOG_FILES),
+ (logFiles, status, deleteFilesExecuting) => {
+ const {
+ isFetching,
+ items
+ } = logFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Log Files',
+ location: combinePath(isWindows, appData, ['logs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLogFiles,
+ executeCommand
+};
+
+class LogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLogFiles();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
+ this.props.fetchLogFiles();
+ }
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchLogFiles();
+ }
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector);
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css
new file mode 100644
index 000000000..779794b7d
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css
@@ -0,0 +1,5 @@
+.download {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js
new file mode 100644
index 000000000..7ae61a531
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './LogFilesTableRow.css';
+
+class LogFilesTableRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ filename,
+ lastWriteTime,
+ downloadUrl
+ } = this.props;
+
+ return (
+
+ {filename}
+
+
+
+
+
+ Download
+
+
+
+ );
+ }
+
+}
+
+LogFilesTableRow.propTypes = {
+ filename: PropTypes.string.isRequired,
+ lastWriteTime: PropTypes.string.isRequired,
+ downloadUrl: PropTypes.string.isRequired
+};
+
+export default LogFilesTableRow;
diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js
new file mode 100644
index 000000000..fa0be453e
--- /dev/null
+++ b/frontend/src/System/Logs/Logs.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import LogFilesConnector from './Files/LogFilesConnector';
+import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector';
+
+class Logs extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default Logs;
diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js
new file mode 100644
index 000000000..b69630248
--- /dev/null
+++ b/frontend/src/System/Logs/LogsNavMenu.js
@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class LogsNavMenu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMenuOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onMenuButtonPress = () => {
+ this.setState({ isMenuOpen: !this.state.isMenuOpen });
+ }
+
+ onMenuItemPress = () => {
+ this.setState({ isMenuOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ current
+ } = this.props;
+
+ return (
+
+
+ {current}
+
+
+
+ Log Files
+
+
+
+ Updater Log Files
+
+
+
+ );
+ }
+}
+
+LogsNavMenu.propTypes = {
+ current: PropTypes.string.isRequired
+};
+
+export default LogsNavMenu;
diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
new file mode 100644
index 000000000..3030c12ce
--- /dev/null
+++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import combinePath from 'Utilities/String/combinePath';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchUpdateLogFiles } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogFiles from '../Files/LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.updateLogFiles,
+ (state) => state.system.status.item,
+ createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES),
+ (updateLogFiles, status, deleteFilesExecuting) => {
+ const {
+ isFetching,
+ items
+ } = updateLogFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Updater Log Files',
+ location: combinePath(isWindows, appData, ['UpdateLogs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchUpdateLogFiles,
+ executeCommand
+};
+
+class UpdateLogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUpdateLogFiles();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
+ this.props.fetchUpdateLogFiles();
+ }
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchUpdateLogFiles();
+ }
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UpdateLogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchUpdateLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector);
diff --git a/frontend/src/System/Status/About/About.css b/frontend/src/System/Status/About/About.css
new file mode 100644
index 000000000..fc20848b0
--- /dev/null
+++ b/frontend/src/System/Status/About/About.css
@@ -0,0 +1,5 @@
+.descriptionList {
+ composes: descriptionList from 'Components/DescriptionList/DescriptionList.css';
+
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js
new file mode 100644
index 000000000..0965baff4
--- /dev/null
+++ b/frontend/src/System/Status/About/About.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import FieldSet from 'Components/FieldSet';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import StartTime from './StartTime';
+import styles from './About.css';
+
+class About extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ version,
+ isMonoRuntime,
+ runtimeVersion,
+ appData,
+ startupPath,
+ mode,
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isMonoRuntime &&
+
+ }
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+ );
+ }
+
+}
+
+About.propTypes = {
+ version: PropTypes.string.isRequired,
+ isMonoRuntime: PropTypes.bool.isRequired,
+ runtimeVersion: PropTypes.string.isRequired,
+ appData: PropTypes.string.isRequired,
+ startupPath: PropTypes.string.isRequired,
+ mode: PropTypes.string.isRequired,
+ startTime: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+export default About;
diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js
new file mode 100644
index 000000000..475d9778b
--- /dev/null
+++ b/frontend/src/System/Status/About/AboutConnector.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchStatus } from 'Store/Actions/systemActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import About from './About';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ createUISettingsSelector(),
+ (status, uiSettings) => {
+ return {
+ ...status.item,
+ timeFormat: uiSettings.timeFormat,
+ longDateFormat: uiSettings.longDateFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchStatus
+};
+
+class AboutConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchStatus();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AboutConnector.propTypes = {
+ fetchStatus: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector);
diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js
new file mode 100644
index 000000000..94b4322d5
--- /dev/null
+++ b/frontend/src/System/Status/About/StartTime.js
@@ -0,0 +1,93 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+
+function getUptime(startTime) {
+ return formatTimeSpan(moment().diff(startTime));
+}
+
+class StartTime extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = props;
+
+ this._timeoutId = null;
+
+ this.state = {
+ uptime: getUptime(startTime),
+ startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
+ };
+ }
+
+ componentDidMount() {
+ this._timeoutId = setTimeout(this.onTimeout, 1000);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ if (
+ startTime !== prevProps.startTime ||
+ timeFormat !== prevProps.timeFormat ||
+ longDateFormat !== prevProps.longDateFormat
+ ) {
+ this.setState({
+ uptime: getUptime(startTime),
+ startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._timeoutId) {
+ this._timeoutId = clearTimeout(this._timeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ onTimeout = () => {
+ this.setState({ uptime: getUptime(this.props.startTime) });
+ this._timeoutId = setTimeout(this.onTimeout, 1000);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ uptime,
+ startTime
+ } = this.state;
+
+ return (
+
+ {uptime}
+
+ );
+ }
+}
+
+StartTime.propTypes = {
+ startTime: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+export default StartTime;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css
new file mode 100644
index 000000000..70ef6f884
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css
@@ -0,0 +1,5 @@
+.space {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js
new file mode 100644
index 000000000..ad1de4d64
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js
@@ -0,0 +1,120 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './DiskSpace.css';
+
+const columns = [
+ {
+ name: 'path',
+ label: 'Location',
+ isVisible: true
+ },
+ {
+ name: 'freeSpace',
+ label: 'Free Space',
+ isVisible: true
+ },
+ {
+ name: 'totalSpace',
+ label: 'Total Space',
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ isVisible: true
+ }
+];
+
+class DiskSpace extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items
+ } = this.props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching &&
+
+
+ {
+ items.map((item) => {
+ const {
+ freeSpace,
+ totalSpace
+ } = item;
+
+ const diskUsage = (100 - freeSpace / totalSpace * 100);
+ let diskUsageKind = kinds.PRIMARY;
+
+ if (diskUsage > 90) {
+ diskUsageKind = kinds.DANGER;
+ } else if (diskUsage > 80) {
+ diskUsageKind = kinds.WARNING;
+ }
+
+ return (
+
+
+ {item.path}
+
+ {
+ item.label &&
+ ` (${item.label})`
+ }
+
+
+
+ {formatBytes(freeSpace)}
+
+
+
+ {formatBytes(totalSpace)}
+
+
+
+
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+DiskSpace.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default DiskSpace;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
new file mode 100644
index 000000000..3049b2ead
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDiskSpace } from 'Store/Actions/systemActions';
+import DiskSpace from './DiskSpace';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.diskSpace,
+ (diskSpace) => {
+ const {
+ isFetching,
+ items
+ } = diskSpace;
+
+ return {
+ isFetching,
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDiskSpace
+};
+
+class DiskSpaceConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDiskSpace();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DiskSpaceConnector.propTypes = {
+ fetchDiskSpace: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector);
diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css
new file mode 100644
index 000000000..1aad8ee77
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.css
@@ -0,0 +1,21 @@
+.legend {
+ display: flex;
+ justify-content: space-between;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 2px;
+ margin-left: 10px;
+ text-align: left;
+}
+
+.status {
+ width: 20px;
+}
+
+.healthOk {
+ margin-bottom: 25px;
+}
+
diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js
new file mode 100644
index 000000000..32fee59dc
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.js
@@ -0,0 +1,206 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './Health.css';
+
+function getInternalLink(source) {
+ switch (source) {
+ case 'IndexerRssCheck':
+ case 'IndexerSearchCheck':
+ case 'IndexerStatusCheck':
+ return (
+
+ );
+ case 'DownloadClientCheck':
+ case 'ImportMechanismCheck':
+ return (
+
+ );
+ case 'RootFolderCheck':
+ return (
+
+ );
+ case 'UpdateCheck':
+ return (
+
+ );
+ default:
+ return;
+ }
+}
+
+function getTestLink(source, props) {
+ switch (source) {
+ case 'IndexerStatusCheck':
+ return (
+
+ );
+ case 'DownloadClientCheck':
+ return (
+
+ );
+
+ default:
+ break;
+ }
+}
+
+const columns = [
+ {
+ className: styles.status,
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: 'Actions',
+ isVisible: true
+ }
+];
+
+class Health extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = this.props;
+
+ const healthIssues = !!items.length;
+
+ return (
+
+ Health
+
+ {
+ isFetching && isPopulated &&
+
+ }
+
+ }
+ >
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !healthIssues &&
+
+ No issues with your configuration
+
+ }
+
+ {
+ healthIssues &&
+
+
+ {
+ items.map((item) => {
+ const internalLink = getInternalLink(item.source);
+ const testLink = getTestLink(item.source, this.props);
+
+ return (
+
+
+
+
+
+ {item.message}
+
+
+
+
+ {
+ internalLink
+ }
+
+ {
+ !!testLink &&
+ testLink
+ }
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+Health.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ isTestingAllDownloadClients: PropTypes.bool.isRequired,
+ isTestingAllIndexers: PropTypes.bool.isRequired,
+ dispatchTestAllDownloadClients: PropTypes.func.isRequired,
+ dispatchTestAllIndexers: PropTypes.func.isRequired
+};
+
+export default Health;
diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js
new file mode 100644
index 000000000..d2adc41cc
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthConnector.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions';
+import Health from './Health';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.health,
+ (state) => state.settings.downloadClients.isTestingAll,
+ (state) => state.settings.indexers.isTestingAll,
+ (health, isTestingAllDownloadClients, isTestingAllIndexers) => {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = health;
+
+ return {
+ isFetching,
+ isPopulated,
+ items,
+ isTestingAllDownloadClients,
+ isTestingAllIndexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchHealth: fetchHealth,
+ dispatchTestAllDownloadClients: testAllDownloadClients,
+ dispatchTestAllIndexers: testAllIndexers
+};
+
+class HealthConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchHealth();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchHealth,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+HealthConnector.propTypes = {
+ dispatchFetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector);
diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js
new file mode 100644
index 000000000..181eae916
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthStatusConnector.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ (state) => state.system.health,
+ (app, health) => {
+ const count = health.items.length;
+ let errors = false;
+ let warnings = false;
+
+ health.items.forEach((item) => {
+ if (item.type === 'error') {
+ errors = true;
+ }
+
+ if (item.type === 'warning') {
+ warnings = true;
+ }
+ });
+
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: health.isPopulated,
+ count,
+ errors,
+ warnings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHealth
+};
+
+class HealthStatusConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.fetchHealth();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.isConnected && prevProps.isReconnecting) {
+ this.props.fetchHealth();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HealthStatusConnector.propTypes = {
+ isConnected: PropTypes.bool.isRequired,
+ isReconnecting: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector);
diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js
new file mode 100644
index 000000000..f338b3f1f
--- /dev/null
+++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js
@@ -0,0 +1,52 @@
+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
+
+ radarr.video
+
+
+ Wiki
+
+ github.com/Radarr/Radarr/wiki
+
+
+ Donations
+
+ radarr.video/donate
+
+
+ Source
+
+ github.com/Radarr/Radarr
+
+
+ Feature Requests
+
+ github.com/Radarr/Radarr/issues
+
+
+
+
+ );
+ }
+}
+
+MoreInfo.propTypes = {
+
+};
+
+export default MoreInfo;
diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js
new file mode 100644
index 000000000..f0b157515
--- /dev/null
+++ b/frontend/src/System/Status/Status.js
@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import HealthConnector from './Health/HealthConnector';
+import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector';
+import AboutConnector from './About/AboutConnector';
+import MoreInfo from './MoreInfo/MoreInfo';
+
+class Status extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default Status;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css
new file mode 100644
index 000000000..30f86efff
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css
@@ -0,0 +1,31 @@
+.trigger {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.triggerContent {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.queued,
+.started,
+.ended {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 180px;
+}
+
+.duration {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js
new file mode 100644
index 000000000..4aa6d76d6
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js
@@ -0,0 +1,265 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './QueuedTaskRow.css';
+
+function getStatusIconProps(status, message) {
+ const title = titleCase(status);
+
+ switch (status) {
+ case 'queued':
+ return {
+ name: icons.PENDING,
+ title
+ };
+
+ case 'started':
+ return {
+ name: icons.REFRESH,
+ isSpinning: true,
+ title
+ };
+
+ case 'completed':
+ return {
+ name: icons.CHECK,
+ kind: kinds.SUCCESS,
+ title: message === 'Completed' ? title : `${title}: ${message}`
+ };
+
+ case 'failed':
+ return {
+ name: icons.FATAL,
+ kind: kinds.ERROR,
+ title: `${title}: ${message}`
+ };
+
+ default:
+ return {
+ name: icons.UNKNOWN,
+ title
+ };
+ }
+}
+
+function getFormattedDates(props) {
+ const {
+ queued,
+ started,
+ ended,
+ showRelativeDates,
+ shortDateFormat
+ } = props;
+
+ if (showRelativeDates) {
+ return {
+ queuedAt: moment(queued).fromNow(),
+ startedAt: started ? moment(started).fromNow() : '-',
+ endedAt: ended ? moment(ended).fromNow() : '-'
+ };
+ }
+
+ return {
+ queuedAt: formatDate(queued, shortDateFormat),
+ startedAt: started ? formatDate(started, shortDateFormat) : '-',
+ endedAt: ended ? formatDate(ended, shortDateFormat) : '-'
+ };
+}
+
+class QueuedTaskRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ ...getFormattedDates(props),
+ isCancelConfirmModalOpen: false
+ };
+
+ this._updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ this.setUpdateTimer();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ queued,
+ started,
+ ended
+ } = this.props;
+
+ if (
+ queued !== prevProps.queued ||
+ started !== prevProps.started ||
+ ended !== prevProps.ended
+ ) {
+ this.setState(getFormattedDates(this.props));
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._updateTimeoutId) {
+ this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
+ }
+ }
+
+ //
+ // Control
+
+ setUpdateTimer() {
+ this._updateTimeoutId = setTimeout(() => {
+ this.setState(getFormattedDates(this.props));
+ this.setUpdateTimer();
+ }, 30000);
+ }
+
+ //
+ // Listeners
+
+ onCancelPress = () => {
+ this.setState({
+ isCancelConfirmModalOpen: true
+ });
+ }
+
+ onAbortCancel = () => {
+ this.setState({
+ isCancelConfirmModalOpen: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ trigger,
+ commandName,
+ queued,
+ started,
+ ended,
+ status,
+ duration,
+ message,
+ longDateFormat,
+ timeFormat,
+ onCancelPress
+ } = this.props;
+
+ const {
+ queuedAt,
+ startedAt,
+ endedAt,
+ isCancelConfirmModalOpen
+ } = this.state;
+
+ let triggerIcon = icons.UNKNOWN;
+
+ if (trigger === 'manual') {
+ triggerIcon = icons.INTERACTIVE;
+ } else if (trigger === 'scheduled') {
+ triggerIcon = icons.SCHEDULED;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {commandName}
+
+
+ {queuedAt}
+
+
+
+ {startedAt}
+
+
+
+ {endedAt}
+
+
+
+ {formatTimeSpan(duration)}
+
+
+
+ {
+ status === 'queued' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+QueuedTaskRow.propTypes = {
+ trigger: PropTypes.string.isRequired,
+ commandName: PropTypes.string.isRequired,
+ queued: PropTypes.string.isRequired,
+ started: PropTypes.string,
+ ended: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ duration: PropTypes.string,
+ message: PropTypes.string,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onCancelPress: PropTypes.func.isRequired
+};
+
+export default QueuedTaskRow;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js
new file mode 100644
index 000000000..f55ab985a
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js
@@ -0,0 +1,31 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { cancelCommand } from 'Store/Actions/commandActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import QueuedTaskRow from './QueuedTaskRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return {
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onCancelPress() {
+ dispatch(cancelCommand({
+ id: props.id
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow);
diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js
new file mode 100644
index 000000000..a2fd526fa
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTasks.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import QueuedTaskRowConnector from './QueuedTaskRowConnector';
+
+const columns = [
+ {
+ name: 'trigger',
+ label: '',
+ isVisible: true
+ },
+ {
+ name: 'commandName',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'queued',
+ label: 'Queued',
+ isVisible: true
+ },
+ {
+ name: 'started',
+ label: 'Started',
+ isVisible: true
+ },
+ {
+ name: 'ended',
+ label: 'Ended',
+ isVisible: true
+ },
+ {
+ name: 'duration',
+ label: 'Duration',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+function QueuedTasks(props) {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+}
+
+QueuedTasks.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default QueuedTasks;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js
new file mode 100644
index 000000000..5fa4d9ead
--- /dev/null
+++ b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchCommands } from 'Store/Actions/commandActions';
+import QueuedTasks from './QueuedTasks';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.commands,
+ (commands) => {
+ return commands;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchCommands: fetchCommands
+};
+
+class QueuedTasksConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchCommands();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueuedTasksConnector.propTypes = {
+ dispatchFetchCommands: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector);
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css
new file mode 100644
index 000000000..dc83cfd69
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css
@@ -0,0 +1,18 @@
+.interval {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.lastExecution,
+.nextExecution {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 180px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js
new file mode 100644
index 000000000..82cedc720
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js
@@ -0,0 +1,182 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import { icons } from 'Helpers/Props';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './ScheduledTaskRow.css';
+
+function getFormattedDates(props) {
+ const {
+ lastExecution,
+ nextExecution,
+ interval,
+ showRelativeDates,
+ shortDateFormat
+ } = props;
+
+ const isDisabled = interval === 0;
+
+ if (showRelativeDates) {
+ return {
+ lastExecutionTime: moment(lastExecution).fromNow(),
+ nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow()
+ };
+ }
+
+ return {
+ lastExecutionTime: formatDate(lastExecution, shortDateFormat),
+ nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat)
+ };
+}
+
+class ScheduledTaskRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = getFormattedDates(props);
+
+ this._updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ this.setUpdateTimer();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ lastExecution,
+ nextExecution
+ } = this.props;
+
+ if (
+ lastExecution !== prevProps.lastExecution ||
+ nextExecution !== prevProps.nextExecution
+ ) {
+ this.setState(getFormattedDates(this.props));
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._updateTimeoutId) {
+ this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
+ }
+ }
+
+ //
+ // Listeners
+
+ setUpdateTimer() {
+ const { interval } = this.props;
+ const timeout = interval < 60 ? 10000 : 60000;
+
+ this._updateTimeoutId = setTimeout(() => {
+ this.setState(getFormattedDates(this.props));
+ this.setUpdateTimer();
+ }, timeout);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ interval,
+ lastExecution,
+ nextExecution,
+ isQueued,
+ isExecuting,
+ longDateFormat,
+ timeFormat,
+ onExecutePress
+ } = this.props;
+
+ const {
+ lastExecutionTime,
+ nextExecutionTime
+ } = this.state;
+
+ const isDisabled = interval === 0;
+ const executeNow = !isDisabled && moment().isAfter(nextExecution);
+ const hasNextExecutionTime = !isDisabled && !executeNow;
+ const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1');
+
+ return (
+
+ {name}
+
+ {isDisabled ? 'disabled' : duration}
+
+
+
+ {lastExecutionTime}
+
+
+ {
+ isDisabled &&
+ -
+ }
+
+ {
+ executeNow && isQueued &&
+ queued
+ }
+
+ {
+ executeNow && !isQueued &&
+ now
+ }
+
+ {
+ hasNextExecutionTime &&
+
+ {nextExecutionTime}
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ScheduledTaskRow.propTypes = {
+ name: PropTypes.string.isRequired,
+ interval: PropTypes.number.isRequired,
+ lastExecution: PropTypes.string.isRequired,
+ nextExecution: PropTypes.string.isRequired,
+ isQueued: PropTypes.bool.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onExecutePress: PropTypes.func.isRequired
+};
+
+export default ScheduledTaskRow;
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js
new file mode 100644
index 000000000..79a0c6c87
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand, isCommandExecuting } from 'Utilities/Command';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchTask } from 'Store/Actions/systemActions';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import ScheduledTaskRow from './ScheduledTaskRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { taskName }) => taskName,
+ createCommandsSelector(),
+ createUISettingsSelector(),
+ (taskName, commands, uiSettings) => {
+ const command = findCommand(commands, { name: taskName });
+
+ return {
+ isQueued: !!(command && command.state === 'queued'),
+ isExecuting: isCommandExecuting(command),
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ const taskName = props.taskName;
+
+ return {
+ dispatchFetchTask() {
+ dispatch(fetchTask({
+ id: props.id
+ }));
+ },
+
+ onExecutePress() {
+ dispatch(executeCommand({
+ name: taskName
+ }));
+ }
+ };
+}
+
+class ScheduledTaskRowConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ isExecuting,
+ dispatchFetchTask
+ } = this.props;
+
+ if (!isExecuting && prevProps.isExecuting) {
+ // Give the host a moment to update after the command completes
+ setTimeout(() => {
+ dispatchFetchTask();
+ }, 1000);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchTask,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+ScheduledTaskRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ dispatchFetchTask: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector);
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js
new file mode 100644
index 000000000..7c6fe8a32
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import ScheduledTaskRowConnector from './ScheduledTaskRowConnector';
+
+const columns = [
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'interval',
+ label: 'Interval',
+ isVisible: true
+ },
+ {
+ name: 'lastExecution',
+ label: 'Last Execution',
+ isVisible: true
+ },
+ {
+ name: 'nextExecution',
+ label: 'Next Execution',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+function ScheduledTasks(props) {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+}
+
+ScheduledTasks.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default ScheduledTasks;
diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js
new file mode 100644
index 000000000..8f418d3bb
--- /dev/null
+++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchTasks } from 'Store/Actions/systemActions';
+import ScheduledTasks from './ScheduledTasks';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.tasks,
+ (tasks) => {
+ return tasks;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchTasks: fetchTasks
+};
+
+class ScheduledTasksConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchTasks();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ScheduledTasksConnector.propTypes = {
+ dispatchFetchTasks: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector);
diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js
new file mode 100644
index 000000000..dbbb4d1bf
--- /dev/null
+++ b/frontend/src/System/Tasks/Tasks.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
+import QueuedTasksConnector from './Queued/QueuedTasksConnector';
+
+function Tasks() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default Tasks;
diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css
new file mode 100644
index 000000000..d21897373
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.css
@@ -0,0 +1,4 @@
+.title {
+ margin-top: 10px;
+ font-size: 16px;
+}
diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js
new file mode 100644
index 000000000..63c7e0d85
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './UpdateChanges.css';
+
+class UpdateChanges extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ changes
+ } = this.props;
+
+ if (changes.length === 0) {
+ return null;
+ }
+
+ return (
+
+
{title}
+
+ {
+ changes.map((change, index) => {
+ return (
+
+ {change}
+
+ );
+ })
+ }
+
+
+ );
+ }
+
+}
+
+UpdateChanges.propTypes = {
+ title: PropTypes.string.isRequired,
+ changes: PropTypes.arrayOf(PropTypes.string)
+};
+
+export default UpdateChanges;
diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css
new file mode 100644
index 000000000..3502f6d1f
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.css
@@ -0,0 +1,57 @@
+.updateAvailable {
+ display: flex;
+}
+
+.upToDate {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+.upToDateIcon {
+ color: #37bc9b;
+ font-size: 30px;
+}
+
+.upToDateMessage {
+ padding-left: 5px;
+ font-size: 18px;
+ line-height: 30px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 5px;
+ margin-left: auto;
+}
+
+.update {
+ margin-top: 20px;
+}
+
+.info {
+ display: flex;
+ align-items: center;
+ margin-bottom: 10px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+}
+
+.version {
+ font-size: 21px;
+}
+
+.space {
+ padding: 0 5px;
+}
+
+.date {
+ font-size: 16px;
+}
+
+.branch {
+ composes: label from 'Components/Label.css';
+
+ margin-left: 10px;
+ font-size: 14px;
+}
diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js
new file mode 100644
index 000000000..215d1aa5b
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.js
@@ -0,0 +1,169 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import formatDate from 'Utilities/Date/formatDate';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import UpdateChanges from './UpdateChanges';
+import styles from './Updates.css';
+
+class Updates extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ shortDateFormat,
+ onInstallLatestPress
+ } = this.props;
+
+ const hasUpdates = isPopulated && !error && items.length > 0;
+ const noUpdates = isPopulated && !error && !items.length;
+ const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true });
+ const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
+
+ return (
+
+
+ {
+ !isPopulated && !error &&
+
+ }
+
+ {
+ noUpdates &&
+ No updates are available
+ }
+
+ {
+ hasUpdateToInstall &&
+
+
+ Install Latest
+
+
+ {
+ isFetching &&
+
+ }
+
+ }
+
+ {
+ noUpdateToInstall &&
+
+
+
+ The latest version of Radarr is already installed
+
+
+ {
+ isFetching &&
+
+ }
+
+ }
+
+ {
+ hasUpdates &&
+
+ {
+ items.map((update) => {
+ const hasChanges = !!update.changes;
+
+ return (
+
+
+
{update.version}
+
—
+
{formatDate(update.releaseDate, shortDateFormat)}
+
+ {
+ update.branch !== 'master' &&
+
+ {update.branch}
+
+ }
+
+
+ {
+ !hasChanges &&
+
Maintenance release
+ }
+
+ {
+ hasChanges &&
+
+
+
+
+
+ }
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!error &&
+
+ Failed to fetch updates
+
+ }
+
+
+ );
+ }
+
+}
+
+Updates.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ isInstallingUpdate: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ onInstallLatestPress: PropTypes.func.isRequired
+};
+
+export default Updates;
diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js
new file mode 100644
index 000000000..0d0aa491f
--- /dev/null
+++ b/frontend/src/System/Updates/UpdatesConnector.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import * as commandNames from 'Commands/commandNames';
+import Updates from './Updates';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.updates,
+ createUISettingsSelector(),
+ createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
+ (updates, uiSettings, isInstallingUpdate) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = updates;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ shortDateFormat: uiSettings.shortDateFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchUpdates,
+ executeCommand
+};
+
+class UpdatesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUpdates();
+ }
+
+ //
+ // Listeners
+
+ onInstallLatestPress = () => {
+ this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UpdatesConnector.propTypes = {
+ fetchUpdates: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector);
diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js
new file mode 100644
index 000000000..165bb5cc1
--- /dev/null
+++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js
@@ -0,0 +1,13 @@
+import _ from 'lodash';
+
+export default function getIndexOfFirstCharacter(items, character) {
+ return _.findIndex(items, (item) => {
+ const firstCharacter = item.sortTitle.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+}
diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js
new file mode 100644
index 000000000..1956d3bac
--- /dev/null
+++ b/frontend/src/Utilities/Array/sortByName.js
@@ -0,0 +1,5 @@
+function sortByName(a, b) {
+ return a.name.localeCompare(b.name);
+}
+
+export default sortByName;
diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js
new file mode 100644
index 000000000..cf7d5444a
--- /dev/null
+++ b/frontend/src/Utilities/Command/findCommand.js
@@ -0,0 +1,10 @@
+import _ from 'lodash';
+import isSameCommand from './isSameCommand';
+
+function findCommand(commands, options) {
+ return _.findLast(commands, (command) => {
+ return isSameCommand(command.body, options);
+ });
+}
+
+export default findCommand;
diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js
new file mode 100644
index 000000000..66043bf03
--- /dev/null
+++ b/frontend/src/Utilities/Command/index.js
@@ -0,0 +1,5 @@
+export { default as findCommand } from './findCommand';
+export { default as isCommandComplete } from './isCommandComplete';
+export { default as isCommandExecuting } from './isCommandExecuting';
+export { default as isCommandFailed } from './isCommandFailed';
+export { default as isSameCommand } from './isSameCommand';
diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js
new file mode 100644
index 000000000..558ab801b
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandComplete.js
@@ -0,0 +1,9 @@
+function isCommandComplete(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'complete';
+}
+
+export default isCommandComplete;
diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js
new file mode 100644
index 000000000..8e637704e
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandExecuting.js
@@ -0,0 +1,9 @@
+function isCommandExecuting(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'queued' || command.status === 'started';
+}
+
+export default isCommandExecuting;
diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js
new file mode 100644
index 000000000..00e5ccdf2
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandFailed.js
@@ -0,0 +1,12 @@
+function isCommandFailed(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.status === 'failed' ||
+ command.status === 'aborted' ||
+ command.status === 'cancelled' ||
+ command.status === 'orphaned';
+}
+
+export default isCommandFailed;
diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js
new file mode 100644
index 000000000..d0acb24b5
--- /dev/null
+++ b/frontend/src/Utilities/Command/isSameCommand.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+
+function isSameCommand(commandA, commandB) {
+ if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) {
+ return false;
+ }
+
+ for (const key in commandB) {
+ if (key !== 'name') {
+ const value = commandB[key];
+ if (Array.isArray(value)) {
+ if (_.difference(value, commandA[key]).length > 0) {
+ return false;
+ }
+ } else if (value !== commandA[key]) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+export default isSameCommand;
diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js
new file mode 100644
index 000000000..9285b10fe
--- /dev/null
+++ b/frontend/src/Utilities/Constants/keyCodes.js
@@ -0,0 +1,7 @@
+export const TAB = 9;
+export const ENTER = 13;
+export const SHIFT = 16;
+export const CONTROL = 17;
+export const ESCAPE = 27;
+export const UP_ARROW = 38;
+export const DOWN_ARROW = 40;
diff --git a/frontend/src/Utilities/Date/dateFilterPredicate.js b/frontend/src/Utilities/Date/dateFilterPredicate.js
new file mode 100644
index 000000000..2c74f435a
--- /dev/null
+++ b/frontend/src/Utilities/Date/dateFilterPredicate.js
@@ -0,0 +1,33 @@
+import moment from 'moment';
+import isAfter from 'Utilities/Date/isAfter';
+import isBefore from 'Utilities/Date/isBefore';
+import * as filterTypes from 'Helpers/Props/filterTypes';
+
+export default function(itemValue, filterValue, type) {
+ if (!itemValue) {
+ return false;
+ }
+
+ switch (type) {
+ case filterTypes.LESS_THAN:
+ return moment(itemValue).isBefore(filterValue);
+
+ case filterTypes.GREATER_THAN:
+ return moment(itemValue).isAfter(filterValue);
+
+ case filterTypes.IN_LAST:
+ return (
+ isAfter(itemValue, { [filterValue.time]: filterValue.value * -1 }) &&
+ isBefore(itemValue)
+ );
+
+ case filterTypes.IN_NEXT:
+ return (
+ isAfter(itemValue) &&
+ isBefore(itemValue, { [filterValue.time]: filterValue.value })
+ );
+
+ default:
+ return false;
+ }
+}
diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js
new file mode 100644
index 000000000..92eb57840
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDate.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function formatDate(date, dateFormat) {
+ if (!date) {
+ return '';
+ }
+
+ return moment(date).format(dateFormat);
+}
+
+export default formatDate;
diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js
new file mode 100644
index 000000000..f36f4f3e0
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDateTime.js
@@ -0,0 +1,39 @@
+import moment from 'moment';
+import formatTime from './formatTime';
+import isToday from './isToday';
+import isTomorrow from './isTomorrow';
+import isYesterday from './isYesterday';
+
+function getRelativeDay(date, includeRelativeDate) {
+ if (!includeRelativeDate) {
+ return '';
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday, ';
+ }
+
+ if (isToday(date)) {
+ return 'Today, ';
+ }
+
+ if (isTomorrow(date)) {
+ return 'Tomorrow, ';
+ }
+
+ return '';
+}
+
+function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ const relativeDay = getRelativeDay(date, includeRelativeDay);
+ const formattedDate = moment(date).format(dateFormat);
+ const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+
+ return `${relativeDay}${formattedDate} ${formattedTime}`;
+}
+
+export default formatDateTime;
diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js
new file mode 100644
index 000000000..89c908d1f
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTime.js
@@ -0,0 +1,19 @@
+import moment from 'moment';
+
+function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ if (includeSeconds) {
+ timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss');
+ } else if (includeMinuteZero) {
+ timeFormat = timeFormat.replace('(:mm)', ':mm');
+ } else {
+ timeFormat = timeFormat.replace('(:mm)', '');
+ }
+
+ return moment(date).format(timeFormat);
+}
+
+export default formatTime;
diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js
new file mode 100644
index 000000000..ef1a278e5
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTimeSpan.js
@@ -0,0 +1,24 @@
+import moment from 'moment';
+import padNumber from 'Utilities/Number/padNumber';
+
+function formatTimeSpan(timeSpan) {
+ if (!timeSpan) {
+ return '';
+ }
+
+ const duration = moment.duration(timeSpan);
+ const days = duration.get('days');
+ const hours = padNumber(duration.get('hours'), 2);
+ const minutes = padNumber(duration.get('minutes'), 2);
+ const seconds = padNumber(duration.get('seconds'), 2);
+
+ const time = `${hours}:${minutes}:${seconds}`;
+
+ if (days > 0) {
+ return `${days}d ${time}`;
+ }
+
+ return time;
+}
+
+export default formatTimeSpan;
diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.js
new file mode 100644
index 000000000..0a60135ce
--- /dev/null
+++ b/frontend/src/Utilities/Date/getRelativeDate.js
@@ -0,0 +1,42 @@
+import moment from 'moment';
+import formatTime from 'Utilities/Date/formatTime';
+import isInNextWeek from 'Utilities/Date/isInNextWeek';
+import isToday from 'Utilities/Date/isToday';
+import isTomorrow from 'Utilities/Date/isTomorrow';
+import isYesterday from 'Utilities/Date/isYesterday';
+
+function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) {
+ if (!date) {
+ return null;
+ }
+
+ const isTodayDate = isToday(date);
+
+ if (isTodayDate && timeForToday && timeFormat) {
+ return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+ }
+
+ if (!showRelativeDates) {
+ return moment(date).format(shortDateFormat);
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday';
+ }
+
+ if (isTodayDate) {
+ return 'Today';
+ }
+
+ if (isTomorrow(date)) {
+ return 'Tomorrow';
+ }
+
+ if (isInNextWeek(date)) {
+ return moment(date).format('dddd');
+ }
+
+ return moment(date).format(shortDateFormat);
+}
+
+export default getRelativeDate;
diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js
new file mode 100644
index 000000000..4bbd8660b
--- /dev/null
+++ b/frontend/src/Utilities/Date/isAfter.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isAfter(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isAfter(offsetTime);
+}
+
+export default isAfter;
diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js
new file mode 100644
index 000000000..3e1e81f67
--- /dev/null
+++ b/frontend/src/Utilities/Date/isBefore.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isBefore(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isBefore(offsetTime);
+}
+
+export default isBefore;
diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js
new file mode 100644
index 000000000..7b5fd7cc7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isInNextWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isInNextWeek(date) {
+ if (!date) {
+ return false;
+ }
+ const now = moment();
+ return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day'));
+}
+
+export default isInNextWeek;
diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js
new file mode 100644
index 000000000..14b76ffb7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isSameWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isSameWeek(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'week');
+}
+
+export default isSameWeek;
diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js
new file mode 100644
index 000000000..31502951f
--- /dev/null
+++ b/frontend/src/Utilities/Date/isToday.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isToday(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'day');
+}
+
+export default isToday;
diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js
new file mode 100644
index 000000000..d22386dbd
--- /dev/null
+++ b/frontend/src/Utilities/Date/isTomorrow.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isTomorrow(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment().add(1, 'day'), 'day');
+}
+
+export default isTomorrow;
diff --git a/frontend/src/Utilities/Date/isYesterday.js b/frontend/src/Utilities/Date/isYesterday.js
new file mode 100644
index 000000000..9de21d82a
--- /dev/null
+++ b/frontend/src/Utilities/Date/isYesterday.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isYesterday(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment().subtract(1, 'day'), 'day');
+}
+
+export default isYesterday;
diff --git a/frontend/src/Utilities/Episode/updateEpisodes.js b/frontend/src/Utilities/Episode/updateEpisodes.js
new file mode 100644
index 000000000..80890b53f
--- /dev/null
+++ b/frontend/src/Utilities/Episode/updateEpisodes.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { update } from 'Store/Actions/baseActions';
+
+function updateEpisodes(section, episodes, episodeIds, options) {
+ const data = _.reduce(episodes, (result, item) => {
+ if (episodeIds.indexOf(item.id) > -1) {
+ result.push({
+ ...item,
+ ...options
+ });
+ } else {
+ result.push(item);
+ }
+
+ return result;
+ }, []);
+
+ return update({ section, data });
+}
+
+export default updateEpisodes;
diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js
new file mode 100644
index 000000000..1c104073c
--- /dev/null
+++ b/frontend/src/Utilities/Filter/findSelectedFilters.js
@@ -0,0 +1,19 @@
+export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) {
+ if (!selectedFilterKey) {
+ return [];
+ }
+
+ let selectedFilter = filters.find((f) => f.key === selectedFilterKey);
+
+ if (!selectedFilter) {
+ selectedFilter = customFilters.find((f) => f.id === selectedFilterKey);
+ }
+
+ if (!selectedFilter) {
+ // TODO: throw in dev
+ console.error('Matching filter not found');
+ return [];
+ }
+
+ return selectedFilter.filters;
+}
diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js
new file mode 100644
index 000000000..70b0b51f1
--- /dev/null
+++ b/frontend/src/Utilities/Filter/getFilterValue.js
@@ -0,0 +1,11 @@
+export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) {
+ const filter = filters.find((f) => f.key === filterKey);
+
+ if (!filter) {
+ return defaultValue;
+ }
+
+ const filterValue = filter.filters.find((f) => f.key === filterValueKey);
+
+ return filterValue ? filterValue.value : defaultValue;
+}
diff --git a/frontend/src/Utilities/Movie/getNewMovie.js b/frontend/src/Utilities/Movie/getNewMovie.js
new file mode 100644
index 000000000..1b4fa012e
--- /dev/null
+++ b/frontend/src/Utilities/Movie/getNewMovie.js
@@ -0,0 +1,18 @@
+
+function getNewMovie(movie, payload) {
+ const {
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ tags
+ } = payload;
+
+ movie.monitored = monitor === 'true';
+ movie.qualityProfileId = qualityProfileId;
+ movie.rootFolderPath = rootFolderPath;
+ movie.tags = tags;
+
+ return movie;
+}
+
+export default getNewMovie;
diff --git a/frontend/src/Utilities/Movie/getProgressBarKind.js b/frontend/src/Utilities/Movie/getProgressBarKind.js
new file mode 100644
index 000000000..cc270f5e8
--- /dev/null
+++ b/frontend/src/Utilities/Movie/getProgressBarKind.js
@@ -0,0 +1,23 @@
+import { kinds } from 'Helpers/Props';
+
+function getProgressBarKind(status, monitored, hasFile) {
+ if (status === 'announced') {
+ return kinds.PRIMARY;
+ }
+
+ if (hasFile && monitored) {
+ return kinds.SUCCESS;
+ }
+
+ if (hasFile && !monitored) {
+ return kinds.DEFAULT;
+ }
+
+ if (monitored) {
+ return kinds.DANGER;
+ }
+
+ return kinds.WARNING;
+}
+
+export default getProgressBarKind;
diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js
new file mode 100644
index 000000000..6c63fb117
--- /dev/null
+++ b/frontend/src/Utilities/Number/convertToBytes.js
@@ -0,0 +1,16 @@
+
+function convertToBytes(input, power, binaryPrefix) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ const prefix = binaryPrefix ? 1024 : 1000;
+ const multiplier = Math.pow(prefix, power);
+ const result = size * multiplier;
+
+ return Math.round(result);
+}
+
+export default convertToBytes;
diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js
new file mode 100644
index 000000000..b8a4aacc5
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatAge.js
@@ -0,0 +1,17 @@
+function formatAge(age, ageHours, ageMinutes) {
+ age = Math.round(age);
+ ageHours = parseFloat(ageHours);
+ ageMinutes = ageMinutes && parseFloat(ageMinutes);
+
+ if (age < 2 && ageHours) {
+ if (ageHours < 2 && !!ageMinutes) {
+ return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`;
+ }
+
+ return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`;
+ }
+
+ return `${age} ${age === 1 ? 'day' : 'days'}`;
+}
+
+export default formatAge;
diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js
new file mode 100644
index 000000000..1ff1b5a97
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatBytes.js
@@ -0,0 +1,16 @@
+import filesize from 'filesize';
+
+function formatBytes(input) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ return filesize(size, {
+ base: 2,
+ round: 1
+ });
+}
+
+export default formatBytes;
diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js
new file mode 100644
index 000000000..53ae69cac
--- /dev/null
+++ b/frontend/src/Utilities/Number/padNumber.js
@@ -0,0 +1,10 @@
+function padNumber(input, width, paddingCharacter = 0) {
+ if (input == null) {
+ return '';
+ }
+
+ input = `${input}`;
+ return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input;
+}
+
+export default padNumber;
diff --git a/frontend/src/Utilities/Number/roundNumber.js b/frontend/src/Utilities/Number/roundNumber.js
new file mode 100644
index 000000000..e1a19018f
--- /dev/null
+++ b/frontend/src/Utilities/Number/roundNumber.js
@@ -0,0 +1,5 @@
+export default function roundNumber(input, decimalPlaces = 1) {
+ const multiplier = Math.pow(10, decimalPlaces);
+
+ return Math.round(input * multiplier) / multiplier;
+}
diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js
new file mode 100644
index 000000000..1ba874660
--- /dev/null
+++ b/frontend/src/Utilities/Object/getErrorMessage.js
@@ -0,0 +1,11 @@
+function getErrorMessage(xhr, fallbackErrorMessage) {
+ if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) {
+ return fallbackErrorMessage;
+ }
+
+ const message = xhr.responseJSON.message;
+
+ return message || fallbackErrorMessage;
+}
+
+export default getErrorMessage;
diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js
new file mode 100644
index 000000000..f89c99a10
--- /dev/null
+++ b/frontend/src/Utilities/Object/hasDifferentItems.js
@@ -0,0 +1,10 @@
+import _ from 'lodash';
+
+function hasDifferentItems(prevItems, currentItems, idProp = 'id') {
+ const diff1 = _.differenceBy(prevItems, currentItems, (item) => item[idProp]);
+ const diff2 = _.differenceBy(currentItems, prevItems, (item) => item[idProp]);
+
+ return diff1.length > 0 || diff2.length > 0;
+}
+
+export default hasDifferentItems;
diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js
new file mode 100644
index 000000000..c2c0c17e3
--- /dev/null
+++ b/frontend/src/Utilities/Object/selectUniqueIds.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+
+function selectUniqueIds(items, idProp) {
+ const ids = _.reduce(items, (result, item) => {
+ if (item[idProp]) {
+ result.push(item[idProp]);
+ }
+
+ return result;
+ }, []);
+
+ return _.uniq(ids);
+}
+
+export default selectUniqueIds;
diff --git a/frontend/src/Utilities/Quality/getQualities.js b/frontend/src/Utilities/Quality/getQualities.js
new file mode 100644
index 000000000..da09851ea
--- /dev/null
+++ b/frontend/src/Utilities/Quality/getQualities.js
@@ -0,0 +1,16 @@
+export default function getQualities(qualities) {
+ if (!qualities) {
+ return [];
+ }
+
+ return qualities.reduce((acc, item) => {
+ if (item.quality) {
+ acc.push(item.quality);
+ } else {
+ const groupQualities = item.items.map((i) => i.quality);
+ acc.push(...groupQualities);
+ }
+
+ return acc;
+ }, []);
+}
diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js
new file mode 100644
index 000000000..358448ca9
--- /dev/null
+++ b/frontend/src/Utilities/ResolutionUtility.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+
+module.exports = {
+ resolutions: {
+ desktopLarge: 1200,
+ desktop: 992,
+ tablet: 768,
+ mobile: 480
+ },
+
+ isDesktopLarge() {
+ return $(window).width() < this.resolutions.desktopLarge;
+ },
+
+ isDesktop() {
+ return $(window).width() < this.resolutions.desktop;
+ },
+
+ isTablet() {
+ return $(window).width() < this.resolutions.tablet;
+ },
+
+ isMobile() {
+ return $(window).width() < this.resolutions.mobile;
+ }
+};
diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js
new file mode 100644
index 000000000..60923a646
--- /dev/null
+++ b/frontend/src/Utilities/State/getProviderState.js
@@ -0,0 +1,42 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function getProviderState(payload, getState, section) {
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const state = getSectionState(getState(), section, true);
+ const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload);
+ const pendingFields = state.pendingChanges.fields || {};
+ delete pendingChanges.fields;
+
+ const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {};
+
+ if (item.fields) {
+ pendingChanges.fields = _.reduce(item.fields, (result, field) => {
+ const name = field.name;
+
+ const value = pendingFields.hasOwnProperty(name) ?
+ pendingFields[name] :
+ field.value;
+
+ // Only send the name and value to the server
+ result.push({
+ name,
+ value
+ });
+
+ return result;
+ }, []);
+ }
+
+ const result = Object.assign({}, item, pendingChanges);
+
+ delete result.presets;
+
+ return result;
+}
+
+export default getProviderState;
diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js
new file mode 100644
index 000000000..00871bed2
--- /dev/null
+++ b/frontend/src/Utilities/State/getSectionState.js
@@ -0,0 +1,22 @@
+import _ from 'lodash';
+
+function getSectionState(state, section, isFullStateTree = false) {
+ if (isFullStateTree) {
+ return _.get(state, section);
+ }
+
+ const [, subSection] = section.split('.');
+
+ if (subSection) {
+ return Object.assign({}, state[subSection]);
+ }
+
+ // TODO: Remove in favour of using subSection
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state[section]);
+ }
+
+ return Object.assign({}, state);
+}
+
+export default getSectionState;
diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js
new file mode 100644
index 000000000..c8a31760c
--- /dev/null
+++ b/frontend/src/Utilities/State/selectProviderSchema.js
@@ -0,0 +1,34 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function applySchemaDefaults(selectedSchema, schemaDefaults) {
+ if (!schemaDefaults) {
+ return selectedSchema;
+ } else if (_.isFunction(schemaDefaults)) {
+ return schemaDefaults(selectedSchema);
+ }
+
+ return Object.assign(selectedSchema, schemaDefaults);
+}
+
+function selectProviderSchema(state, section, payload, schemaDefaults) {
+ const newState = getSectionState(state, section);
+
+ const {
+ implementation,
+ presetName
+ } = payload;
+
+ const selectedImplementation = _.find(newState.schema, { implementation });
+
+ const selectedSchema = presetName ?
+ _.find(selectedImplementation.presets, { name: presetName }) :
+ selectedImplementation;
+
+ newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults);
+
+ return updateSectionState(state, section, newState);
+}
+
+export default selectProviderSchema;
diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js
new file mode 100644
index 000000000..81b33ecaf
--- /dev/null
+++ b/frontend/src/Utilities/State/updateSectionState.js
@@ -0,0 +1,16 @@
+function updateSectionState(state, section, newState) {
+ const [, subSection] = section.split('.');
+
+ if (subSection) {
+ return Object.assign({}, state, { [subSection]: newState });
+ }
+
+ // TODO: Remove in favour of using subSection
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state, { [section]: newState });
+ }
+
+ return Object.assign({}, state, newState);
+}
+
+export default updateSectionState;
diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js
new file mode 100644
index 000000000..9e4e9abe8
--- /dev/null
+++ b/frontend/src/Utilities/String/combinePath.js
@@ -0,0 +1,5 @@
+export default function combinePath(isWindows, basePath, paths = []) {
+ const slash = isWindows ? '\\' : '/';
+
+ return `${basePath}${slash}${paths.join(slash)}`;
+}
diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js
new file mode 100644
index 000000000..51b15ec60
--- /dev/null
+++ b/frontend/src/Utilities/String/generateUUIDv4.js
@@ -0,0 +1,6 @@
+export default function generateUUIDv4() {
+ return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
+ // eslint-disable-next-line no-bitwise
+ (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
+ );
+}
diff --git a/frontend/src/Utilities/String/isString.js b/frontend/src/Utilities/String/isString.js
new file mode 100644
index 000000000..1e7c3dff8
--- /dev/null
+++ b/frontend/src/Utilities/String/isString.js
@@ -0,0 +1,3 @@
+export default function isString(possibleString) {
+ return typeof possibleString === 'string' || possibleString instanceof String;
+}
diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js
new file mode 100644
index 000000000..93341f85f
--- /dev/null
+++ b/frontend/src/Utilities/String/parseUrl.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import qs from 'qs';
+
+// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils
+const anchor = document.createElement('a');
+
+export default function parseUrl(url) {
+ anchor.href = url;
+
+ // The `origin`, `password`, and `username` properties are unavailable in
+ // Opera Presto. We synthesize `origin` if it's not present. While `password`
+ // and `username` are ignored intentionally.
+ const properties = _.pick(
+ anchor,
+ 'hash',
+ 'host',
+ 'hostname',
+ 'href',
+ 'origin',
+ 'pathname',
+ 'port',
+ 'protocol',
+ 'search'
+ );
+
+ properties.isAbsolute = (/^[\w:]*\/\//).test(url);
+
+ if (properties.search) {
+ // Remove leading ? from querystring before parsing.
+ properties.params = qs.parse(properties.search.substring(1));
+ } else {
+ properties.params = {};
+ }
+
+ return properties;
+}
diff --git a/frontend/src/Utilities/String/split.js b/frontend/src/Utilities/String/split.js
new file mode 100644
index 000000000..0e57e7545
--- /dev/null
+++ b/frontend/src/Utilities/String/split.js
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+function split(input, separator = ',') {
+ if (!input) {
+ return [];
+ }
+
+ return _.reduce(input.split(separator), (result, s) => {
+ if (s) {
+ result.push(s);
+ }
+
+ return result;
+ }, []);
+}
+
+export default split;
diff --git a/frontend/src/Utilities/String/titleCase.js b/frontend/src/Utilities/String/titleCase.js
new file mode 100644
index 000000000..5b76c10dd
--- /dev/null
+++ b/frontend/src/Utilities/String/titleCase.js
@@ -0,0 +1,11 @@
+function titleCase(input) {
+ if (!input) {
+ return '';
+ }
+
+ return input.replace(/\b\w+/g, (match) => {
+ return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase();
+ });
+}
+
+export default titleCase;
diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js
new file mode 100644
index 000000000..26102f89b
--- /dev/null
+++ b/frontend/src/Utilities/Table/areAllSelected.js
@@ -0,0 +1,17 @@
+export default function areAllSelected(selectedState) {
+ let allSelected = true;
+ let allUnselected = true;
+
+ Object.keys(selectedState).forEach((key) => {
+ if (selectedState[key]) {
+ allUnselected = false;
+ } else {
+ allSelected = false;
+ }
+ });
+
+ return {
+ allSelected,
+ allUnselected
+ };
+}
diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js
new file mode 100644
index 000000000..705f13a5d
--- /dev/null
+++ b/frontend/src/Utilities/Table/getSelectedIds.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+
+function getSelectedIds(selectedState, { parseIds = true } = {}) {
+ return _.reduce(selectedState, (result, value, id) => {
+ if (value) {
+ const parsedId = parseIds ? parseInt(id) : id;
+
+ result.push(parsedId);
+ }
+
+ return result;
+ }, []);
+}
+
+export default getSelectedIds;
diff --git a/frontend/src/Utilities/Table/getToggledRange.js b/frontend/src/Utilities/Table/getToggledRange.js
new file mode 100644
index 000000000..c0cc44fe5
--- /dev/null
+++ b/frontend/src/Utilities/Table/getToggledRange.js
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+
+function getToggledRange(items, id, lastToggled) {
+ const lastToggledIndex = _.findIndex(items, { id: lastToggled });
+ const changedIndex = _.findIndex(items, { id });
+ let lower = 0;
+ let upper = 0;
+
+ if (lastToggledIndex > changedIndex) {
+ lower = changedIndex;
+ upper = lastToggledIndex + 1;
+ } else {
+ lower = lastToggledIndex;
+ upper = changedIndex;
+ }
+
+ return {
+ lower,
+ upper
+ };
+}
+
+export default getToggledRange;
diff --git a/frontend/src/Utilities/Table/removeOldSelectedState.js b/frontend/src/Utilities/Table/removeOldSelectedState.js
new file mode 100644
index 000000000..ff3a4fe11
--- /dev/null
+++ b/frontend/src/Utilities/Table/removeOldSelectedState.js
@@ -0,0 +1,16 @@
+import areAllSelected from './areAllSelected';
+
+export default function removeOldSelectedState(state, prevItems) {
+ const selectedState = {
+ ...state.selectedState
+ };
+
+ prevItems.forEach((item) => {
+ delete selectedState[item.id];
+ });
+
+ return {
+ ...areAllSelected(selectedState),
+ selectedState
+ };
+}
diff --git a/frontend/src/Utilities/Table/selectAll.js b/frontend/src/Utilities/Table/selectAll.js
new file mode 100644
index 000000000..ffaaeaddf
--- /dev/null
+++ b/frontend/src/Utilities/Table/selectAll.js
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+function selectAll(selectedState, selected) {
+ const newSelectedState = _.reduce(Object.keys(selectedState), (result, item) => {
+ result[item] = selected;
+ return result;
+ }, {});
+
+ return {
+ allSelected: selected,
+ allUnselected: !selected,
+ lastToggled: null,
+ selectedState: newSelectedState
+ };
+}
+
+export default selectAll;
diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js
new file mode 100644
index 000000000..dbc0d6223
--- /dev/null
+++ b/frontend/src/Utilities/Table/toggleSelected.js
@@ -0,0 +1,30 @@
+import areAllSelected from './areAllSelected';
+import getToggledRange from './getToggledRange';
+
+function toggleSelected(state, items, id, selected, shiftKey) {
+ const lastToggled = state.lastToggled;
+ const selectedState = {
+ ...state.selectedState,
+ [id]: selected
+ };
+
+ if (selected == null) {
+ delete selectedState[id];
+ }
+
+ if (shiftKey && lastToggled) {
+ const { lower, upper } = getToggledRange(items, id, lastToggled);
+
+ for (let i = lower; i < upper; i++) {
+ selectedState[items[i].id] = selected;
+ }
+ }
+
+ return {
+ ...areAllSelected(selectedState),
+ lastToggled: id,
+ selectedState
+ };
+}
+
+export default toggleSelected;
diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js
new file mode 100644
index 000000000..7ad8961da
--- /dev/null
+++ b/frontend/src/Utilities/createAjaxRequest.js
@@ -0,0 +1,30 @@
+import $ from 'jquery';
+
+export default function createAjaxRequest(ajaxOptions) {
+ const requestXHR = new window.XMLHttpRequest();
+ let aborted = false;
+ let complete = false;
+
+ function abortRequest() {
+ if (!complete) {
+ aborted = true;
+ requestXHR.abort();
+ }
+ }
+
+ const request = $.ajax({
+ xhr: () => requestXHR,
+ ...ajaxOptions
+ }).then(null, (xhr, textStatus, errorThrown) => {
+ xhr.aborted = aborted;
+
+ return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
+ }).always(() => {
+ complete = true;
+ });
+
+ return {
+ request,
+ abortRequest
+ };
+}
diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js
new file mode 100644
index 000000000..29bbf7ac4
--- /dev/null
+++ b/frontend/src/Utilities/getPathWithUrlBase.js
@@ -0,0 +1,3 @@
+export default function getPathWithUrlBase(path) {
+ return `${window.Radarr.urlBase}${path}`;
+}
diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js
new file mode 100644
index 000000000..dae5150b7
--- /dev/null
+++ b/frontend/src/Utilities/getUniqueElementId.js
@@ -0,0 +1,7 @@
+let i = 0;
+
+// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
+
+export default function getUniqueElementId() {
+ return `id-${i++}`;
+}
diff --git a/frontend/src/Utilities/isMobile.js b/frontend/src/Utilities/isMobile.js
new file mode 100644
index 000000000..489020a23
--- /dev/null
+++ b/frontend/src/Utilities/isMobile.js
@@ -0,0 +1,7 @@
+import MobileDetect from 'mobile-detect';
+
+export default function isMobile() {
+ const mobileDetect = new MobileDetect(window.navigator.userAgent);
+
+ return mobileDetect.mobile() != null;
+}
diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js
new file mode 100644
index 000000000..f58dbe803
--- /dev/null
+++ b/frontend/src/Utilities/pagePopulator.js
@@ -0,0 +1,28 @@
+let currentPopulator = null;
+let currentReasons = [];
+
+export function registerPagePopulator(populator, reasons = []) {
+ currentPopulator = populator;
+ currentReasons = reasons;
+}
+
+export function unregisterPagePopulator(populator) {
+ if (currentPopulator === populator) {
+ currentPopulator = null;
+ currentReasons = [];
+ }
+}
+
+export function repopulatePage(reason) {
+ if (!currentPopulator) {
+ return;
+ }
+
+ if (!reason) {
+ currentPopulator();
+ }
+
+ if (reason && currentReasons.includes(reason)) {
+ currentPopulator();
+ }
+}
diff --git a/frontend/src/Utilities/pages.js b/frontend/src/Utilities/pages.js
new file mode 100644
index 000000000..1355442d9
--- /dev/null
+++ b/frontend/src/Utilities/pages.js
@@ -0,0 +1,9 @@
+const pages = {
+ FIRST: 'first',
+ PREVIOUS: 'previous',
+ NEXT: 'next',
+ LAST: 'last',
+ EXACT: 'exact'
+};
+
+export default pages;
diff --git a/frontend/src/Utilities/requestAction.js b/frontend/src/Utilities/requestAction.js
new file mode 100644
index 000000000..3f2564a7b
--- /dev/null
+++ b/frontend/src/Utilities/requestAction.js
@@ -0,0 +1,40 @@
+import $ from 'jquery';
+import _ from 'lodash';
+
+function flattenProviderData(providerData) {
+ return _.reduce(Object.keys(providerData), (result, key) => {
+ const property = providerData[key];
+
+ if (key === 'fields') {
+ result[key] = property;
+ } else {
+ result[key] = property.value;
+ }
+
+ return result;
+ }, {});
+}
+
+function requestAction(payload) {
+ const {
+ provider,
+ action,
+ providerData,
+ queryParams
+ } = payload;
+
+ const ajaxOptions = {
+ url: `/${provider}/action/${action}`,
+ contentType: 'application/json',
+ method: 'POST',
+ data: JSON.stringify(flattenProviderData(providerData))
+ };
+
+ if (queryParams) {
+ ajaxOptions.url += `?${$.param(queryParams, true)}`;
+ }
+
+ return $.ajax(ajaxOptions);
+}
+
+export default requestAction;
diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js
new file mode 100644
index 000000000..5479b32b9
--- /dev/null
+++ b/frontend/src/Utilities/sectionTypes.js
@@ -0,0 +1,6 @@
+const sectionTypes = {
+ COLLECTION: 'collection',
+ MODEL: 'model'
+};
+
+export default sectionTypes;
diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js
new file mode 100644
index 000000000..03fa39c00
--- /dev/null
+++ b/frontend/src/Utilities/serverSideCollectionHandlers.js
@@ -0,0 +1,12 @@
+const serverSideCollectionHandlers = {
+ FETCH: 'fetch',
+ FIRST_PAGE: 'firstPage',
+ PREVIOUS_PAGE: 'previousPage',
+ NEXT_PAGE: 'nextPage',
+ LAST_PAGE: 'lastPage',
+ EXACT_PAGE: 'exactPage',
+ SORT: 'sort',
+ FILTER: 'filter'
+};
+
+export default serverSideCollectionHandlers;
diff --git a/frontend/src/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..c16e2852f
--- /dev/null
+++ b/frontend/src/index.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Radarr
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..299bccb64
--- /dev/null
+++ b/frontend/src/jQuery/jquery.ajax.js
@@ -0,0 +1,47 @@
+import $ from 'jquery';
+
+const absUrlRegex = /^(https?:)?\/\//i;
+const apiRoot = window.Radarr.apiRoot;
+const urlBase = window.Radarr.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.Radarr.apiKey;
+}
+
+export default function() {
+ const originalAjax = $.ajax;
+ $.ajax = function(xhr) {
+ if (xhr && isRelative(xhr)) {
+ moveBodyToQuery(xhr);
+ addRootUrl(xhr);
+ addApiKey(xhr);
+ }
+ return originalAjax.apply(this, arguments);
+ };
+}
diff --git a/frontend/src/login.html b/frontend/src/login.html
new file mode 100644
index 000000000..216ab3910
--- /dev/null
+++ b/frontend/src/login.html
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login - Radarr
+
+
+
+
+
+
+
+
+
+
+
+
+ SIGN IN TO CONTINUE
+
+
+
+
+
+
+
+ ©
+
+ -
+ Radarr
+
+
+
+
+
+
+
diff --git a/frontend/src/oauth.html b/frontend/src/oauth.html
new file mode 100644
index 000000000..16a34dbf3
--- /dev/null
+++ b/frontend/src/oauth.html
@@ -0,0 +1,13 @@
+
+
+
+
+ OAuth landing page
+
+
+
+ Shouldn't see this
+
+
diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js
new file mode 100644
index 000000000..b5d17d598
--- /dev/null
+++ b/frontend/src/polyfills.js
@@ -0,0 +1,41 @@
+/* eslint no-empty-function: 0 no-extend-native: 0 */
+
+window.console = window.console || {};
+window.console.log = window.console.log || function() {};
+window.console.group = window.console.group || function() {};
+window.console.groupEnd = window.console.groupEnd || function() {};
+window.console.debug = window.console.debug || function() {};
+window.console.warn = window.console.warn || function() {};
+window.console.assert = window.console.assert || function() {};
+
+if (!String.prototype.startsWith) {
+ Object.defineProperty(String.prototype, 'startsWith', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value(searchString, position) {
+ position = position || 0;
+ return this.indexOf(searchString, position) === position;
+ }
+ });
+}
+
+if (!String.prototype.endsWith) {
+ Object.defineProperty(String.prototype, 'endsWith', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value(searchString, position) {
+ position = position || this.length;
+ position = position - searchString.length;
+ const lastIndex = this.lastIndexOf(searchString);
+ return lastIndex !== -1 && lastIndex === position;
+ }
+ });
+}
+
+if (!('contains' in String.prototype)) {
+ String.prototype.contains = function(str, startIndex) {
+ return String.prototype.indexOf.call(this, str, startIndex) !== -1;
+ };
+}
diff --git a/frontend/src/preload.js b/frontend/src/preload.js
new file mode 100644
index 000000000..91708840f
--- /dev/null
+++ b/frontend/src/preload.js
@@ -0,0 +1,4 @@
+/* eslint no-undef: 0 */
+import 'Shims/jquery';
+
+__webpack_public_path__ = `${window.Radarr.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');