License: GPL-3.0+
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/.editorconfig b/frontend/.editorconfig
new file mode 100644
index 000000000..c14ef65ef
--- /dev/null
+++ b/frontend/.editorconfig
@@ -0,0 +1,6 @@
+[*]
+insert_final_newline = true
+
+[*.{js,css}]
+indent_style = space
+indent_size = 2
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..8593e9f61
--- /dev/null
+++ b/frontend/.eslintrc
@@ -0,0 +1,293 @@
+{
+ "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"
+ ],
+
+ "settings": {
+ "react": {
+ "version": "detect"
+ }
+ },
+
+ "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-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/babel.config.js b/frontend/babel.config.js
new file mode 100644
index 000000000..fe855af63
--- /dev/null
+++ b/frontend/babel.config.js
@@ -0,0 +1,35 @@
+const loose = true;
+
+module.exports = {
+ plugins: [
+ // Stage 1
+ '@babel/plugin-proposal-export-default-from',
+ ['@babel/plugin-proposal-optional-chaining', { loose }],
+ ['@babel/plugin-proposal-nullish-coalescing-operator', { loose }],
+
+ // Stage 2
+ '@babel/plugin-proposal-export-namespace-from',
+
+ // Stage 3
+ ['@babel/plugin-proposal-class-properties', { loose }],
+ '@babel/plugin-syntax-dynamic-import'
+ ],
+ env: {
+ development: {
+ presets: [
+ ['@babel/preset-react', { development: true }]
+ ],
+ plugins: [
+ 'babel-plugin-inline-classnames'
+ ]
+ },
+ production: {
+ presets: [
+ '@babel/preset-react'
+ ],
+ plugins: [
+ 'babel-plugin-transform-react-remove-prop-types'
+ ]
+ }
+ }
+};
diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js
new file mode 100644
index 000000000..de2da698f
--- /dev/null
+++ b/frontend/gulp/build.js
@@ -0,0 +1,18 @@
+const gulp = require('gulp');
+
+require('./clean');
+require('./copy');
+require('./webpack');
+
+gulp.task('build',
+ gulp.series('clean',
+ gulp.parallel(
+ '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..8d58ac4a4
--- /dev/null
+++ b/frontend/gulp/copy.js
@@ -0,0 +1,45 @@
+const path = require('path');
+const gulp = require('gulp');
+const print = require('gulp-print').default;
+const cache = require('gulp-cached');
+const livereload = require('gulp-livereload');
+const paths = require('./helpers/paths.js');
+
+gulp.task('copyJs', () => {
+ return gulp.src(
+ [
+ path.join(paths.src.root, 'polyfills.js')
+ ], { base: paths.src.root })
+ .pipe(cache('copyJs'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyHtml', () => {
+ return gulp.src(paths.src.html, { base: paths.src.root })
+ .pipe(cache('copyHtml'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyFonts', () => {
+ return gulp.src(
+ path.join(paths.src.fonts, '**', '*.*'), { base: paths.src.root }
+ )
+ .pipe(cache('copyFonts'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyImages', () => {
+ return gulp.src(
+ path.join(paths.src.images, '**', '*.*'), { base: paths.src.root }
+ )
+ .pipe(cache('copyImages'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js
new file mode 100644
index 000000000..64f14f654
--- /dev/null
+++ b/frontend/gulp/gulpFile.js
@@ -0,0 +1,5 @@
+require('./build.js');
+require('./clean.js');
+require('./copy.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..9c542398d
--- /dev/null
+++ b/frontend/gulp/helpers/errorHandler.js
@@ -0,0 +1,6 @@
+const colors = require('ansi-colors');
+
+module.exports = function errorHandler(error) {
+ console.log(colors.red(`Error (${error.plugin}): ${error.message}`));
+ this.emit('end');
+};
diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js
new file mode 100644
index 000000000..8707faec4
--- /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/watch.js b/frontend/gulp/watch.js
new file mode 100644
index 000000000..f83a4bba4
--- /dev/null
+++ b/frontend/gulp/watch.js
@@ -0,0 +1,18 @@
+const gulp = require('gulp');
+const livereload = require('gulp-livereload');
+const gulpWatch = require('gulp-watch');
+const paths = require('./helpers/paths.js');
+
+require('./copy.js');
+require('./webpack.js');
+
+function watch() {
+ livereload.listen({ start: true });
+
+ gulp.task('webpackWatch')();
+ gulpWatch(paths.src.html, gulp.series('copyHtml'));
+ gulpWatch(`${paths.src.fonts}**/*.*`, gulp.series('copyFonts'));
+ gulpWatch(`${paths.src.images}**/*.*`, gulp.series('copyImages'));
+}
+
+gulp.task('watch', gulp.series('build', watch));
diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js
new file mode 100644
index 000000000..bbef74e58
--- /dev/null
+++ b/frontend/gulp/webpack.js
@@ -0,0 +1,202 @@
+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 OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+
+const uiFolder = 'UI';
+const frontendFolder = path.join(__dirname, '..');
+const srcFolder = path.join(frontendFolder, 'src');
+const isProduction = process.argv.indexOf('--production') > -1;
+
+console.log('Source Folder:', srcFolder);
+console.log('isProduction:', isProduction);
+
+const cssVarsFiles = [
+ '../src/Styles/Variables/colors',
+ '../src/Styles/Variables/dimensions',
+ '../src/Styles/Variables/fonts',
+ '../src/Styles/Variables/animations',
+ '../src/Styles/Variables/zIndexes'
+].map(require.resolve);
+
+const plugins = [
+ new OptimizeCssAssetsPlugin({}),
+
+ new webpack.DefinePlugin({
+ __DEV__: !isProduction,
+ 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development')
+ }),
+
+ new MiniCssExtractPlugin({
+ filename: path.join('_output', uiFolder, 'Content', 'styles.css')
+ })
+];
+
+const config = {
+ mode: isProduction ? 'production' : 'development',
+ devtool: '#source-map',
+
+ stats: {
+ children: false
+ },
+
+ watchOptions: {
+ ignored: /node_modules/
+ },
+
+ entry: {
+ preload: 'preload.js',
+ vendor: 'vendor.js',
+ index: 'index.js'
+ },
+
+ resolve: {
+ modules: [
+ srcFolder,
+ path.join(srcFolder, 'Shims'),
+ 'node_modules'
+ ],
+ alias: {
+ jquery: 'jquery/src/jquery'
+ }
+ },
+
+ output: {
+ filename: path.join('_output', uiFolder, '[name].js'),
+ sourceMapFilename: '[file].map'
+ },
+
+ optimization: {
+ chunkIds: 'named'
+ },
+
+ plugins,
+
+ resolveLoader: {
+ modules: [
+ 'node_modules',
+ 'frontend/gulp/webpack/'
+ ]
+ },
+
+ module: {
+ rules: [
+ {
+ test: /\.js?$/,
+ exclude: /(node_modules|JsLibraries)/,
+ use: [
+ {
+ loader: 'babel-loader',
+ options: {
+ configFile: `${frontendFolder}/babel.config.js`,
+ envName: isProduction ? 'production' : 'development',
+ presets: [
+ [
+ '@babel/preset-env',
+ {
+ modules: false,
+ loose: true,
+ debug: false,
+ useBuiltIns: 'entry',
+ corejs: 3
+ }
+ ]
+ ]
+ }
+ }
+ ]
+ },
+
+ // CSS Modules
+ {
+ test: /\.css$/,
+ exclude: /(node_modules|globals.css)/,
+ use: [
+ { loader: MiniCssExtractPlugin.loader },
+ {
+ loader: 'css-loader',
+ options: {
+ importLoaders: 1,
+ modules: {
+ localIdentName: '[name]/[local]/[hash:base64:5]'
+ }
+ }
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ ident: 'postcss',
+ 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 webpackStream(config, webpack)
+ .pipe(gulp.dest('./'));
+});
+
+gulp.task('webpackWatch', () => {
+ config.watch = true;
+
+ return webpackStream(config, webpack)
+ .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..b0391ec6a
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,23 @@
+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-color-function': {},
+ 'postcss-nested': {}
+ }
+ };
+
+ 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..466cc40b7
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js
@@ -0,0 +1,146 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as blacklistActions from 'Store/Actions/blacklistActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import Blacklist from './Blacklist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.blacklist,
+ createCommandExecutingSelector(commandNames.CLEAR_BLACKLIST),
+ (blacklist, isClearingBlacklistExecuting) => {
+ return {
+ isClearingBlacklistExecuting,
+ ...blacklist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...blacklistActions,
+ executeCommand
+};
+
+class BlacklistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchBlacklist,
+ gotoBlacklistFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchBlacklist();
+ } else {
+ gotoBlacklistFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearBlacklist();
+ unregisterPagePopulator(this.repopulate);
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchBlacklist();
+ }
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoBlacklistFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoBlacklistPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoBlacklistNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoBlacklistLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoBlacklistPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setBlacklistSort({ sortKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setBlacklistTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ onClearBlacklistPress = () => {
+ this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BlacklistConnector.propTypes = {
+ 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..fe431c64a
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.css
@@ -0,0 +1,17 @@
+.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..31813a41d
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.js
@@ -0,0 +1,174 @@
+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 TrackQuality from 'Album/TrackQuality';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import BlacklistDetailsModal from './BlacklistDetailsModal';
+import styles from './BlacklistRow.css';
+
+class BlacklistRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artist,
+ sourceTitle,
+ quality,
+ date,
+ protocol,
+ indexer,
+ message,
+ columns,
+ onRemovePress
+ } = this.props;
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artist.sortName') {
+ 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,
+ artist: 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..a85f1f78b
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { removeFromBlacklist } from 'Store/Actions/blacklistActions';
+import BlacklistRow from './BlacklistRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+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..383f08afd
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.css
@@ -0,0 +1,5 @@
+.description {
+ composes: description 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..ca6e49d22
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.js
@@ -0,0 +1,434 @@
+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 { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import styles from './HistoryDetails.css';
+
+function getDetailedList(statusMessages) {
+ return (
+
+ {
+ statusMessages.map(({ title, messages }) => {
+ return (
+
+ {title}
+
+ {
+ messages.map((message) => {
+ return (
+
+ {message}
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+
+ );
+}
+
+function formatMissing(value) {
+ if (value === undefined || value === 0 || value === '0') {
+ return ( );
+ }
+ return value;
+}
+
+function formatChange(oldValue, newValue) {
+ return (
+ {formatMissing(oldValue)} {formatMissing(newValue)}
+ );
+}
+
+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 === 'trackFileImported') {
+ const {
+ droppedPath,
+ importedPath
+ } = data;
+
+ return (
+
+
+
+ {
+ !!droppedPath &&
+
+ }
+
+ {
+ !!importedPath &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'trackFileDeleted') {
+ const {
+ reason
+ } = data;
+
+ let reasonMessage = '';
+
+ switch (reason) {
+ case 'Manual':
+ reasonMessage = 'File was deleted by via UI';
+ break;
+ case 'MissingFromDisk':
+ reasonMessage = 'Lidarr 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 === 'trackFileRenamed') {
+ const {
+ sourcePath,
+ sourceRelativePath,
+ path,
+ relativePath
+ } = data;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (eventType === 'trackFileRetagged') {
+ const {
+ diff,
+ tagsScrubbed
+ } = data;
+
+ return (
+
+
+ {
+ JSON.parse(diff).map(({ field, oldValue, newValue }) => {
+ return (
+
+ );
+ })
+ }
+ : }
+ />
+
+ );
+ }
+
+ if (eventType === 'albumImportIncomplete') {
+ const {
+ statusMessages
+ } = data;
+
+ return (
+
+
+
+ {
+ !!statusMessages &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'downloadImported') {
+ const {
+ indexer,
+ releaseGroup,
+ nzbInfoUrl,
+ downloadClient,
+ downloadId,
+ age,
+ ageHours,
+ ageMinutes,
+ publishedDate
+ } = data;
+
+ return (
+
+
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!releaseGroup &&
+
+ }
+
+ {
+ !!nzbInfoUrl &&
+
+
+ Info URL
+
+
+
+ {nzbInfoUrl}
+
+
+ }
+
+ {
+ !!downloadClient &&
+
+ }
+
+ {
+ !!downloadId &&
+
+ }
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!publishedDate &&
+
+ }
+
+ );
+ }
+
+ 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..271d422ff
--- /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..865024491
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js
@@ -0,0 +1,110 @@
+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 'trackFileImported':
+ return 'Track Imported';
+ case 'trackFileDeleted':
+ return 'Track File Deleted';
+ case 'trackFileRenamed':
+ return 'Track File Renamed';
+ case 'trackFileRetagged':
+ return 'Track File Tags Updated';
+ case 'albumImportIncomplete':
+ return 'Album Import Incomplete';
+ case 'downloadImported':
+ return 'Download Completed';
+ 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..a525d9988
--- /dev/null
+++ b/frontend/src/Activity/History/History.js
@@ -0,0 +1,172 @@
+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 albums start fetching or when albums start fetching.
+
+ if (
+ (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items)
+ ) ||
+ (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ selectedFilterKey,
+ filters,
+ totalRecords,
+ isAlbumsFetching,
+ isAlbumsPopulated,
+ albumsError,
+ onFilterSelect,
+ onFirstPagePress,
+ ...otherProps
+ } = this.props;
+
+ const isFetchingAny = isFetching || isAlbumsFetching;
+ const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length);
+ const hasError = error || albumsError;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetchingAny && !isAllPopulated &&
+
+ }
+
+ {
+ !isFetchingAny && hasError &&
+ Unable to load history
+ }
+
+ {
+ // If history isPopulated and it's empty show no history found and don't
+ // wait for the albums to populate because they are never coming.
+
+ isPopulated && !hasError && !items.length &&
+
+ No history found
+
+ }
+
+ {
+ isAllPopulated && !hasError && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+ }
+}
+
+History.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isAlbumsFetching: PropTypes.bool.isRequired,
+ isAlbumsPopulated: PropTypes.bool.isRequired,
+ albumsError: PropTypes.object,
+ onFilterSelect: PropTypes.func.isRequired,
+ onFirstPagePress: PropTypes.func.isRequired
+};
+
+export default History;
diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js
new file mode 100644
index 000000000..d8ca60839
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryConnector.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import * as historyActions from 'Store/Actions/historyActions';
+import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
+import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
+import History from './History';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.history,
+ (state) => state.albums,
+ (state) => state.tracks,
+ (history, albums, tracks) => {
+ return {
+ isAlbumsFetching: albums.isFetching,
+ isAlbumsPopulated: albums.isPopulated,
+ albumsError: albums.error,
+ isTracksFetching: tracks.isFetching,
+ isTracksPopulated: tracks.isPopulated,
+ tracksError: tracks.error,
+ ...history
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...historyActions,
+ fetchAlbums,
+ clearAlbums,
+ fetchTracks,
+ clearTracks
+};
+
+class HistoryConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchHistory,
+ gotoHistoryFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchHistory();
+ } else {
+ gotoHistoryFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'albumId');
+ const trackIds = selectUniqueIds(this.props.items, 'trackId');
+ if (albumIds.length) {
+ this.props.fetchAlbums({ albumIds });
+ } else {
+ this.props.clearAlbums();
+ }
+ if (trackIds.length) {
+ this.props.fetchTracks({ trackIds });
+ } else {
+ this.props.clearTracks();
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearHistory();
+ this.props.clearAlbums();
+ this.props.clearTracks();
+ }
+
+ //
+ // 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,
+ fetchAlbums: PropTypes.func.isRequired,
+ clearAlbums: PropTypes.func.isRequired,
+ fetchTracks: PropTypes.func.isRequired,
+ clearTracks: 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..63d79e18c
--- /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..172796cd4
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryEventTypeCell.js
@@ -0,0 +1,96 @@
+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 'artistFolderImported':
+ return icons.DRIVE;
+ case 'trackFileImported':
+ return icons.DOWNLOADED;
+ case 'downloadFailed':
+ return icons.DOWNLOADING;
+ case 'trackFileDeleted':
+ return icons.DELETE;
+ case 'trackFileRenamed':
+ return icons.ORGANIZE;
+ case 'trackFileRetagged':
+ return icons.RETAG;
+ case 'albumImportIncomplete':
+ return icons.DOWNLOADED;
+ case 'downloadImported':
+ return icons.DOWNLOADED;
+ default:
+ return icons.UNKNOWN;
+ }
+}
+
+function getIconKind(eventType) {
+ switch (eventType) {
+ case 'downloadFailed':
+ return kinds.DANGER;
+ case 'albumImportIncomplete':
+ return kinds.WARNING;
+ default:
+ return kinds.DEFAULT;
+ }
+}
+
+function getTooltip(eventType, data) {
+ switch (eventType) {
+ case 'grabbed':
+ return `Album grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
+ case 'artistFolderImported':
+ return 'Track imported from artist folder';
+ case 'trackFileImported':
+ return 'Track downloaded successfully and picked up from download client';
+ case 'downloadFailed':
+ return 'Album download failed';
+ case 'trackFileDeleted':
+ return 'Track file deleted';
+ case 'trackFileRenamed':
+ return 'Track file renamed';
+ case 'trackFileRetagged':
+ return 'Track file tags updated';
+ case 'albumImportIncomplete':
+ return 'Files downloaded but not all could be imported';
+ case 'downloadImported':
+ return 'Download completed and successfully imported';
+ 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..669377fdb
--- /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..62b83ed93
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRow.js
@@ -0,0 +1,241 @@
+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 AlbumTitleLink from 'Album/AlbumTitleLink';
+import TrackQuality from 'Album/TrackQuality';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import HistoryEventTypeCell from './HistoryEventTypeCell';
+import HistoryDetailsModal from './Details/HistoryDetailsModal';
+import styles from './HistoryRow.css';
+
+class HistoryRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.isMarkingAsFailed &&
+ !this.props.isMarkingAsFailed &&
+ !this.props.markAsFailedError
+ ) {
+ this.setState({ isDetailsModalOpen: false });
+ }
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artist,
+ album,
+ track,
+ quality,
+ qualityCutoffNotMet,
+ eventType,
+ sourceTitle,
+ date,
+ data,
+ isMarkingAsFailed,
+ columns,
+ shortDateFormat,
+ timeFormat,
+ onMarkAsFailedPress
+ } = this.props;
+
+ if (!artist || !album) {
+ return null;
+ }
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'eventType') {
+ return (
+
+ );
+ }
+
+ if (name === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'album.title') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'trackTitle') {
+ return (
+
+ {track.title}
+
+ );
+ }
+
+ 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 = {
+ albumId: PropTypes.number,
+ artist: PropTypes.object.isRequired,
+ album: PropTypes.object,
+ track: PropTypes.object,
+ 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
+};
+
+HistoryRow.defaultProps = {
+ track: {
+ title: ''
+ }
+};
+
+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..c1e70edff
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRowConnector.js
@@ -0,0 +1,78 @@
+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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createAlbumSelector from 'Store/Selectors/createAlbumSelector';
+import createTrackSelector from 'Store/Selectors/createTrackSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import HistoryRow from './HistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createAlbumSelector(),
+ createTrackSelector(),
+ createUISettingsSelector(),
+ (artist, album, track, uiSettings) => {
+ return {
+ artist,
+ album,
+ track,
+ 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..259fd5c65
--- /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..9169927a3
--- /dev/null
+++ b/frontend/src/Activity/Queue/Queue.js
@@ -0,0 +1,288 @@
+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 albums start fetching or when albums start fetching.
+
+ if (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items) &&
+ nextProps.items.some((e) => e.albumId)
+ ) {
+ return false;
+ }
+
+ if (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) {
+ 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, skipredownload) => {
+ this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist, skipredownload);
+ this.setState({ isConfirmRemoveModalOpen: false });
+ }
+
+ onConfirmRemoveModalClose = () => {
+ this.setState({ isConfirmRemoveModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isAlbumsFetching,
+ isAlbumsPopulated,
+ albumsError,
+ columns,
+ totalRecords,
+ isGrabbing,
+ isRemoving,
+ isCheckForFinishedDownloadExecuting,
+ onRefreshPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmRemoveModalOpen,
+ isPendingSelected
+ } = this.state;
+
+ const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting;
+ const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length || items.every((e) => !e.albumId));
+ const hasError = error || albumsError;
+ 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,
+ isAlbumsFetching: PropTypes.bool.isRequired,
+ isAlbumsPopulated: PropTypes.bool.isRequired,
+ albumsError: PropTypes.object,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isGrabbing: PropTypes.bool.isRequired,
+ isRemoving: PropTypes.bool.isRequired,
+ isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onGrabSelectedPress: PropTypes.func.isRequired,
+ onRemoveSelectedPress: PropTypes.func.isRequired
+};
+
+export default Queue;
diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js
new file mode 100644
index 000000000..ea571e8af
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueConnector.js
@@ -0,0 +1,188 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as queueActions from 'Store/Actions/queueActions';
+import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
+import * as commandNames from 'Commands/commandNames';
+import Queue from './Queue';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.albums,
+ (state) => state.queue.options,
+ (state) => state.queue.paged,
+ createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD),
+ (albums, options, queue, isCheckForFinishedDownloadExecuting) => {
+ return {
+ isAlbumsFetching: albums.isFetching,
+ isAlbumsPopulated: albums.isPopulated,
+ albumsError: albums.error,
+ isCheckForFinishedDownloadExecuting,
+ ...options,
+ ...queue
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...queueActions,
+ fetchAlbums,
+ clearAlbums,
+ executeCommand
+};
+
+class QueueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchQueue,
+ gotoQueueFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchQueue();
+ } else {
+ gotoQueueFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'albumId');
+
+ if (albumIds.length) {
+ this.props.fetchAlbums({ albumIds });
+ } else {
+ this.props.clearAlbums();
+ }
+ }
+
+ if (
+ this.props.includeUnknownArtistItems !==
+ prevProps.includeUnknownArtistItems
+ ) {
+ this.repopulate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearQueue();
+ this.props.clearAlbums();
+ }
+
+ //
+ // 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, skipredownload) => {
+ this.props.removeQueueItems({ ids, blacklist, skipredownload });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ includeUnknownArtistItems: PropTypes.bool.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,
+ fetchAlbums: PropTypes.func.isRequired,
+ clearAlbums: 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..8256b8af3
--- /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..835be52b3
--- /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 = {
+ includeUnknownArtistItems: props.includeUnknownArtistItems
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ includeUnknownArtistItems
+ } = this.props;
+
+ if (includeUnknownArtistItems !== prevProps.includeUnknownArtistItems) {
+ this.setState({
+ includeUnknownArtistItems
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onOptionChange = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onOptionChange({
+ [name]: value
+ });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ includeUnknownArtistItems
+ } = this.state;
+
+ return (
+
+
+ Show Unknown Artist Items
+
+
+
+
+ );
+ }
+}
+
+QueueOptions.propTypes = {
+ includeUnknownArtistItems: 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..16805dbf6
--- /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: 90px;
+}
diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js
new file mode 100644
index 000000000..06cf16f70
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.js
@@ -0,0 +1,383 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, tooltipPositions } 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 Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import AlbumTitleLink from 'Album/AlbumTitleLink';
+import TrackQuality from 'Album/TrackQuality';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import QueueStatusCell from './QueueStatusCell';
+import TimeleftCell from './TimeleftCell';
+import RemoveQueueItemModal from './RemoveQueueItemModal';
+import styles from './QueueRow.css';
+
+class QueueRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRemoveQueueItemModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRemoveQueueItemPress = () => {
+ this.setState({ isRemoveQueueItemModalOpen: true });
+ }
+
+ onRemoveQueueItemModalConfirmed = (blacklist, skipredownload) => {
+ this.props.onRemoveQueueItemPress(blacklist, skipredownload);
+ 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,
+ artist,
+ album,
+ quality,
+ protocol,
+ indexer,
+ outputPath,
+ downloadClient,
+ downloadForced,
+ 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 === 'artist.sortName') {
+ return (
+
+ {
+ artist ?
+ :
+ title
+ }
+
+ );
+ }
+
+ if (name === 'album.title') {
+ return (
+
+ {
+ album ?
+ :
+ '-'
+ }
+
+ );
+ }
+
+ if (name === 'album.releaseDate') {
+ if (album) {
+ return (
+
+ );
+ }
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'protocol') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'downloadClient') {
+ return (
+
+ {downloadClient}
+
+ );
+ }
+
+ if (name === 'title') {
+ return (
+
+ {title}
+
+ );
+ }
+
+ if (name === 'outputPath') {
+ return (
+
+ {outputPath}
+
+ );
+ }
+
+ if (name === 'estimatedCompletionTime') {
+ return (
+
+ );
+ }
+
+ if (name === 'progress') {
+ return (
+
+ {
+ !!progress &&
+
+ }
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ {
+ downloadForced &&
+
+ }
+ title="Manual Download"
+ body="This release failed parsing checks and was manually downloaded from an interactive search. Import is likely to fail."
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+ {
+ 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,
+ artist: PropTypes.object,
+ album: PropTypes.object,
+ quality: PropTypes.object.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ outputPath: PropTypes.string,
+ downloadClient: PropTypes.string,
+ downloadForced: PropTypes.bool.isRequired,
+ 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..6bbbde361
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRowConnector.js
@@ -0,0 +1,71 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createAlbumSelector from 'Store/Selectors/createAlbumSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import QueueRow from './QueueRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createAlbumSelector(),
+ createUISettingsSelector(),
+ (artist, album, uiSettings) => {
+ const result = _.pick(uiSettings, [
+ 'showRelativeDates',
+ 'shortDateFormat',
+ 'timeFormat'
+ ]);
+
+ result.artist = artist;
+ result.album = album;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ grabQueueItem,
+ removeQueueItem
+};
+
+class QueueRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ this.props.grabQueueItem({ id: this.props.id });
+ }
+
+ onRemoveQueueItemPress = (blacklist, skipredownload) => {
+ this.props.removeQueueItem({ id: this.props.id, blacklist, skipredownload });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ album: 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..e1b9a23e9
--- /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..552fa1444
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueStatusCell.js
@@ -0,0 +1,133 @@
+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}
+ canFlip={false}
+ />
+
+ );
+}
+
+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..d7a643463
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css
@@ -0,0 +1,4 @@
+.messageRemove {
+ margin-bottom: 30px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js
new file mode 100644
index 000000000..d2f929aee
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js
@@ -0,0 +1,145 @@
+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,
+ skipredownload: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onBlacklistChange = ({ value }) => {
+ this.setState({ blacklist: value });
+ }
+
+ onSkipReDownloadChange = ({ value }) => {
+ this.setState({ skipredownload: value });
+ }
+
+ onRemoveQueueItemConfirmed = () => {
+ const blacklist = this.state.blacklist;
+ const skipredownload = this.state.skipredownload;
+
+ this.setState({
+ blacklist: false,
+ skipredownload: false
+ });
+ this.props.onRemovePress(blacklist, skipredownload);
+ }
+
+ onModalClose = () => {
+ this.setState({
+ blacklist: false,
+ skipredownload: false
+ });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ sourceTitle
+ } = this.props;
+
+ const blacklist = this.state.blacklist;
+ const skipredownload = this.state.skipredownload;
+
+ return (
+
+
+
+ Remove - {sourceTitle}
+
+
+
+
+ Are you sure you want to remove '{sourceTitle}' from the queue?
+
+
+
+ Removing will remove the download and the file(s) from the download client.
+
+
+
+ Blacklist Release
+
+
+
+ {
+ blacklist &&
+
+ Skip Redownload
+
+
+ }
+
+
+
+
+
+ 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..b573c5cbd
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js
@@ -0,0 +1,141 @@
+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,
+ skipredownload: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onBlacklistChange = ({ value }) => {
+ this.setState({ blacklist: value });
+ }
+
+ onSkipReDownloadChange = ({ value }) => {
+ this.setState({ skipredownload: value });
+ }
+
+ onRemoveQueueItemConfirmed = () => {
+ const blacklist = this.state.blacklist;
+ const skipredownload = this.state.skipredownload;
+
+ this.setState({
+ blacklist: false,
+ skipredownload: false
+ });
+ this.props.onRemovePress(blacklist, skipredownload);
+ }
+
+ onModalClose = () => {
+ this.setState({
+ blacklist: false,
+ skipredownload: false
+ });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ selectedCount
+ } = this.props;
+
+ const blacklist = this.state.blacklist;
+ const skipredownload = this.state.skipredownload;
+
+ return (
+
+
+
+ Remove Selected Item{selectedCount > 1 ? 's' : ''}
+
+
+
+
+ Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue?
+
+
+
+ Blacklist Release
+
+
+
+ {
+ blacklist &&
+
+ Skip Redownload
+
+
+ }
+
+
+
+
+
+ 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..090b8fc96
--- /dev/null
+++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.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 { 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.includeUnknownArtistItems,
+ (app, status, includeUnknownArtistItems) => {
+ const {
+ errors,
+ warnings,
+ unknownErrors,
+ unknownWarnings,
+ count,
+ totalCount
+ } = status.item;
+
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: status.isPopulated,
+ ...status.item,
+ count: includeUnknownArtistItems ? totalCount : count,
+ errors: includeUnknownArtistItems ? errors || unknownErrors : errors,
+ warnings: includeUnknownArtistItems ? warnings || unknownWarnings : warnings
+ };
+ }
+ );
+}
+
+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..cc6001a22
--- /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/AddArtist/AddNewArtist/AddNewArtist.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css
new file mode 100644
index 000000000..7c558d6d0
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.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/AddArtist/AddNewArtist/AddNewArtist.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js
new file mode 100644
index 000000000..23affe605
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js
@@ -0,0 +1,183 @@
+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 AddNewArtistSearchResultConnector from './AddNewArtistSearchResultConnector';
+import styles from './AddNewArtist.css';
+
+class AddNewArtist 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.onArtistLookupChange(term);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ term,
+ isFetching
+ } = this.props;
+
+ if (term && term !== prevProps.term) {
+ this.setState({
+ term,
+ isFetching: true
+ });
+ this.props.onArtistLookupChange(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.onArtistLookupChange(value);
+ } else {
+ this.props.onClearArtistLookup();
+ }
+ });
+ }
+
+ onClearArtistLookupPress = () => {
+ this.setState({ term: '' });
+ this.props.onClearArtistLookup();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ error,
+ items
+ } = this.props;
+
+ const term = this.state.term;
+ const isFetching = this.state.isFetching;
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Failed to load search results, please try again.
+ }
+
+ {
+ !isFetching && !error && !!items.length &&
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !isFetching && !error && !items.length && !!term &&
+
+
Couldn't find any results for '{term}'
+
You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
+
+
+ Why can't I find my artist?
+
+
+
+ }
+
+ {
+ !term &&
+
+
It's easy to add a new artist, just start typing the name the artist you want to add.
+
You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AddNewArtist.propTypes = {
+ term: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isAdding: PropTypes.bool.isRequired,
+ addError: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onArtistLookupChange: PropTypes.func.isRequired,
+ onClearArtistLookup: PropTypes.func.isRequired
+};
+
+export default AddNewArtist;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js
new file mode 100644
index 000000000..50cc07cd2
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.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 { lookupArtist, clearAddArtist } from 'Store/Actions/addArtistActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import AddNewArtist from './AddNewArtist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.router.location,
+ (addArtist, location) => {
+ const { params } = parseUrl(location.search);
+
+ return {
+ term: params.term,
+ ...addArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ lookupArtist,
+ clearAddArtist,
+ fetchRootFolders
+};
+
+class AddNewArtistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._artistLookupTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ componentWillUnmount() {
+ if (this._artistLookupTimeout) {
+ clearTimeout(this._artistLookupTimeout);
+ }
+
+ this.props.clearAddArtist();
+ }
+
+ //
+ // Listeners
+
+ onArtistLookupChange = (term) => {
+ if (this._artistLookupTimeout) {
+ clearTimeout(this._artistLookupTimeout);
+ }
+
+ if (term.trim() === '') {
+ this.props.clearAddArtist();
+ } else {
+ this._artistLookupTimeout = setTimeout(() => {
+ this.props.lookupArtist({ term });
+ }, 300);
+ }
+ }
+
+ onClearArtistLookup = () => {
+ this.props.clearAddArtist();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ term,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+AddNewArtistConnector.propTypes = {
+ term: PropTypes.string,
+ lookupArtist: PropTypes.func.isRequired,
+ clearAddArtist: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistConnector);
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js
new file mode 100644
index 000000000..e94a8a229
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNewArtistModalContentConnector from './AddNewArtistModalContentConnector';
+
+function AddNewArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AddNewArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNewArtistModal;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css
new file mode 100644
index 000000000..4c5c747a8
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css
@@ -0,0 +1,76 @@
+.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;
+ max-height: 230px;
+ text-align: justify;
+}
+
+.labelIcon {
+ margin-left: 8px;
+}
+
+.searchForMissingAlbumsLabelContainer {
+ display: flex;
+ margin-top: 2px;
+}
+
+.searchForMissingAlbumsLabel {
+ margin-right: 8px;
+ font-weight: normal;
+}
+
+.searchForMissingAlbumsContainer {
+ composes: container from '~Components/Form/CheckInput.css';
+
+ flex: 0 1 0;
+}
+
+.searchForMissingAlbumsInput {
+ 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';
+}
+
+.hideMetadataProfile {
+ composes: group from '~Components/Form/FormGroup.css';
+
+ display: none;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalFooter {
+ display: block;
+ text-align: center;
+ }
+
+ .addButton {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js
new file mode 100644
index 000000000..2278812b8
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js
@@ -0,0 +1,241 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import CheckInput from 'Components/Form/CheckInput';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Popover from 'Components/Tooltip/Popover';
+import ArtistPoster from 'Artist/ArtistPoster';
+import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
+import styles from './AddNewArtistModalContent.css';
+
+class AddNewArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ searchForMissingAlbums: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onSearchForMissingAlbumsChange = ({ value }) => {
+ this.setState({ searchForMissingAlbums: value });
+ }
+
+ onQualityProfileIdChange = ({ value }) => {
+ this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
+ }
+
+ onMetadataProfileIdChange = ({ value }) => {
+ this.props.onInputChange({ name: 'metadataProfileId', value: parseInt(value) });
+ }
+
+ onAddArtistPress = () => {
+ this.props.onAddArtistPress(this.state.searchForMissingAlbums);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ overview,
+ images,
+ isAdding,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ metadataProfileId,
+ albumFolder,
+ tags,
+ showMetadataProfile,
+ isSmallScreen,
+ onModalClose,
+ onInputChange,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {artistName}
+
+
+
+
+ {
+ isSmallScreen ?
+ null:
+
+ }
+
+
+ {
+ overview ?
+
+
+
:
+ null
+ }
+
+
+
+
+
+
+
+
+
+ Start search for missing albums
+
+
+
+
+
+
+ Add {artistName}
+
+
+
+ );
+ }
+}
+
+AddNewArtistModalContent.propTypes = {
+ artistName: PropTypes.string.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,
+ metadataProfileId: PropTypes.object,
+ albumFolder: PropTypes.object.isRequired,
+ tags: PropTypes.object.isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onAddArtistPress: PropTypes.func.isRequired
+};
+
+export default AddNewArtistModalContent;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js
new file mode 100644
index 000000000..049d05813
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js
@@ -0,0 +1,105 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setAddArtistDefault, addArtist } from 'Store/Actions/addArtistActions';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import selectSettings from 'Store/Selectors/selectSettings';
+import AddNewArtistModalContent from './AddNewArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.settings.metadataProfiles,
+ createDimensionsSelector(),
+ (addArtistState, metadataProfiles, dimensions) => {
+ const {
+ isAdding,
+ addError,
+ defaults
+ } = addArtistState;
+
+ const {
+ settings,
+ validationErrors,
+ validationWarnings
+ } = selectSettings(defaults, {}, addError);
+
+ return {
+ isAdding,
+ addError,
+ showMetadataProfile: metadataProfiles.items.length > 1,
+ isSmallScreen: dimensions.isSmallScreen,
+ validationErrors,
+ validationWarnings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setAddArtistDefault,
+ addArtist
+};
+
+class AddNewArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setAddArtistDefault({ [name]: value });
+ }
+
+ onAddArtistPress = (searchForMissingAlbums) => {
+ const {
+ foreignArtistId,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ metadataProfileId,
+ albumFolder,
+ tags
+ } = this.props;
+
+ this.props.addArtist({
+ foreignArtistId,
+ rootFolderPath: rootFolderPath.value,
+ monitor: monitor.value,
+ qualityProfileId: qualityProfileId.value,
+ metadataProfileId: metadataProfileId.value,
+ albumFolder: albumFolder.value,
+ tags: tags.value,
+ searchForMissingAlbums
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNewArtistModalContentConnector.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ metadataProfileId: PropTypes.object,
+ albumFolder: PropTypes.object.isRequired,
+ tags: PropTypes.object.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ setAddArtistDefault: PropTypes.func.isRequired,
+ addArtist: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistModalContentConnector);
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css
new file mode 100644
index 000000000..c56765538
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css
@@ -0,0 +1,42 @@
+.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;
+}
+
+.name {
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.year {
+ margin-left: 10px;
+ color: $disabledColor;
+}
+
+.alreadyExistsIcon {
+ margin-left: 10px;
+ color: #37bc9b;
+}
+
+.overview {
+ overflow: hidden;
+ margin-top: 20px;
+ text-align: justify;
+}
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js
new file mode 100644
index 000000000..8c5e54cbc
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js
@@ -0,0 +1,207 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import dimensions from 'Styles/Variables/dimensions';
+import fonts from 'Styles/Variables/fonts';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import ArtistPoster from 'Artist/ArtistPoster';
+import AddNewArtistModal from './AddNewArtistModal';
+import styles from './AddNewArtistSearchResult.css';
+
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const defaultFontSize = parseInt(fonts.defaultFontSize);
+const lineHeight = parseFloat(fonts.lineHeight);
+
+function calculateHeight(rowHeight, isSmallScreen) {
+ let height = rowHeight - 45;
+
+ if (isSmallScreen) {
+ height -= columnPaddingSmallScreen;
+ } else {
+ height -= columnPadding;
+ }
+
+ return height;
+}
+
+class AddNewArtistSearchResult extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isNewAddArtistModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.isExistingArtist && this.props.isExistingArtist) {
+ this.onAddArtistModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isNewAddArtistModalOpen: true });
+ }
+
+ onAddArtistModalClose = () => {
+ this.setState({ isNewAddArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ foreignArtistId,
+ artistName,
+ year,
+ disambiguation,
+ artistType,
+ status,
+ overview,
+ ratings,
+ images,
+ isExistingArtist,
+ isSmallScreen
+ } = this.props;
+
+ const {
+ isNewAddArtistModalOpen
+ } = this.state;
+
+ const linkProps = isExistingArtist ? { to: `/artist/${foreignArtistId}` } : { onPress: this.onPress };
+
+ const endedString = artistType === 'Person' ? 'Deceased' : 'Ended';
+
+ const height = calculateHeight(230, isSmallScreen);
+
+ return (
+
+
+ {
+ isSmallScreen ?
+ null :
+
+ }
+
+
+
+ {artistName}
+
+ {
+ !name.contains(year) && year ?
+
+ ({year})
+ :
+ null
+ }
+
+ {
+ !!disambiguation &&
+ ({disambiguation})
+ }
+
+ {
+ isExistingArtist ?
+ :
+ null
+ }
+
+
+
+
+
+
+
+ {
+ artistType ?
+
+ {artistType}
+ :
+ null
+ }
+
+ {
+ status === 'ended' ?
+
+ {endedString}
+ :
+ null
+ }
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+AddNewArtistSearchResult.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ year: PropTypes.number,
+ disambiguation: PropTypes.string,
+ artistType: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ ratings: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isExistingArtist: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired
+};
+
+export default AddNewArtistSearchResult;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js
new file mode 100644
index 000000000..45165c04d
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import AddNewArtistSearchResult from './AddNewArtistSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingArtistSelector(),
+ createDimensionsSelector(),
+ (isExistingArtist, dimensions) => {
+ return {
+ isExistingArtist,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(AddNewArtistSearchResult);
diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js
new file mode 100644
index 000000000..5b53425a4
--- /dev/null
+++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+
+function ArtistMonitoringOptionsPopoverContent() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ArtistMonitoringOptionsPopoverContent;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js
new file mode 100644
index 000000000..3ed2459d1
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import ImportArtistTableConnector from './ImportArtistTableConnector';
+import ImportArtistFooterConnector from './ImportArtistFooterConnector';
+
+class ImportArtist 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,
+ showMetadataProfile
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ contentBody
+ } = this.state;
+
+ return (
+
+
+ {
+ rootFoldersFetching && !rootFoldersPopulated &&
+
+ }
+
+ {
+ !rootFoldersFetching && !!rootFoldersError &&
+ Unable to load root folders
+ }
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length &&
+
+ All artist in {path} have been imported
+
+ }
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody &&
+
+ }
+
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length &&
+
+ }
+
+ );
+ }
+}
+
+ImportArtist.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),
+ showMetadataProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired
+};
+
+ImportArtist.defaultProps = {
+ unmappedFolders: []
+};
+
+export default ImportArtist;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js
new file mode 100644
index 000000000..4ce182bbd
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js
@@ -0,0 +1,170 @@
+/* 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 { setImportArtistValue, importArtist, clearImportArtist } from 'Store/Actions/importArtistActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { setAddArtistDefault } from 'Store/Actions/addArtistActions';
+import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
+import ImportArtist from './ImportArtist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ (state) => state.rootFolders,
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ (state) => state.settings.qualityProfiles,
+ (state) => state.settings.metadataProfiles,
+ (
+ match,
+ rootFolders,
+ addArtist,
+ importArtistState,
+ qualityProfiles,
+ metadataProfiles
+ ) => {
+ const {
+ isFetching: rootFoldersFetching,
+ isPopulated: rootFoldersPopulated,
+ error: rootFoldersError,
+ items
+ } = rootFolders;
+
+ const rootFolderId = parseInt(match.params.rootFolderId);
+
+ const result = {
+ rootFolderId,
+ rootFoldersFetching,
+ rootFoldersPopulated,
+ rootFoldersError,
+ qualityProfiles: qualityProfiles.items,
+ metadataProfiles: metadataProfiles.items,
+ showMetadataProfile: metadataProfiles.items.length > 1,
+ defaultQualityProfileId: addArtist.defaults.qualityProfileId,
+ defaultMetadataProfileId: addArtist.defaults.metadataProfileId
+ };
+
+ if (items.length) {
+ const rootFolder = _.find(items, { id: rootFolderId });
+
+ return {
+ ...result,
+ ...rootFolder,
+ items: importArtistState.items
+ };
+ }
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetImportArtistValue: setImportArtistValue,
+ dispatchImportArtist: importArtist,
+ dispatchClearImportArtist: clearImportArtist,
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchSetAddArtistDefault: setAddArtistDefault
+};
+
+class ImportArtistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ qualityProfiles,
+ metadataProfiles,
+ defaultQualityProfileId,
+ defaultMetadataProfileId,
+ dispatchFetchRootFolders,
+ dispatchSetAddArtistDefault
+ } = 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 (
+ !defaultMetadataProfileId ||
+ !metadataProfiles.some((p) => p.id === defaultMetadataProfileId)
+ ) {
+ setDefaults = true;
+ setDefaultPayload.metadataProfileId = metadataProfiles[0].id;
+ }
+
+ if (setDefaults) {
+ dispatchSetAddArtistDefault(setDefaultPayload);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearImportArtist();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (ids, name, value) => {
+ this.props.dispatchSetAddArtistDefault({ [name]: value });
+
+ ids.forEach((id) => {
+ this.props.dispatchSetImportArtistValue({
+ id,
+ [name]: value
+ });
+ });
+ }
+
+ onImportPress = (ids) => {
+ this.props.dispatchImportArtist({ ids });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+const routeMatchShape = createRouteMatchShape({
+ rootFolderId: PropTypes.string.isRequired
+});
+
+ImportArtistConnector.propTypes = {
+ match: routeMatchShape.isRequired,
+ rootFoldersPopulated: PropTypes.bool.isRequired,
+ qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ metadataProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ defaultQualityProfileId: PropTypes.number.isRequired,
+ defaultMetadataProfileId: PropTypes.number.isRequired,
+ dispatchSetImportArtistValue: PropTypes.func.isRequired,
+ dispatchImportArtist: PropTypes.func.isRequired,
+ dispatchClearImportArtist: PropTypes.func.isRequired,
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchSetAddArtistDefault: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistConnector);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css
new file mode 100644
index 000000000..616aeaf3c
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.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/AddArtist/ImportArtist/Import/ImportArtistFooter.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js
new file mode 100644
index 000000000..a0feaad89
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js
@@ -0,0 +1,261 @@
+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 './ImportArtistFooter.css';
+
+const MIXED = 'mixed';
+
+class ImportArtistFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultMetadataProfileId,
+ defaultAlbumFolder
+ } = props;
+
+ this.state = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ metadataProfileId: defaultMetadataProfileId,
+ albumFolder: defaultAlbumFolder
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultMetadataProfileId,
+ defaultAlbumFolder,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isMetadataProfileIdMixed,
+ isAlbumFolderMixed
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId,
+ metadataProfileId,
+ albumFolder
+ } = 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 (isMetadataProfileIdMixed && metadataProfileId !== MIXED) {
+ newState.metadataProfileId = MIXED;
+ } else if (!isMetadataProfileIdMixed && metadataProfileId !== defaultMetadataProfileId) {
+ newState.metadataProfileId = defaultMetadataProfileId;
+ }
+
+ if (isAlbumFolderMixed && albumFolder != null) {
+ newState.albumFolder = null;
+ } else if (!isAlbumFolderMixed && albumFolder !== defaultAlbumFolder) {
+ newState.albumFolder = defaultAlbumFolder;
+ }
+
+ if (!_.isEmpty(newState)) {
+ this.setState(newState);
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ this.props.onInputChange({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedCount,
+ isImporting,
+ isLookingUpArtist,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isMetadataProfileIdMixed,
+ hasUnsearchedItems,
+ showMetadataProfile,
+ onImportPress,
+ onLookupPress,
+ onCancelLookupPress
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId,
+ metadataProfileId,
+ albumFolder
+ } = this.state;
+
+ return (
+
+
+
+
+
+ Quality Profile
+
+
+
+
+
+ {
+ showMetadataProfile &&
+
+
+ Metadata Profile
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+ Import {selectedCount} Artist(s)
+
+
+ {
+ isLookingUpArtist &&
+
+ Cancel Processing
+
+ }
+
+ {
+ hasUnsearchedItems &&
+
+ Start Processing
+
+ }
+
+ {
+ isLookingUpArtist &&
+
+ }
+
+ {
+ isLookingUpArtist &&
+ 'Processing Folders'
+ }
+
+
+
+ );
+ }
+}
+
+ImportArtistFooter.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ isImporting: PropTypes.bool.isRequired,
+ isLookingUpArtist: PropTypes.bool.isRequired,
+ defaultMonitor: PropTypes.string.isRequired,
+ defaultQualityProfileId: PropTypes.number,
+ defaultMetadataProfileId: PropTypes.number,
+ defaultAlbumFolder: PropTypes.bool.isRequired,
+ isMonitorMixed: PropTypes.bool.isRequired,
+ isQualityProfileIdMixed: PropTypes.bool.isRequired,
+ isMetadataProfileIdMixed: PropTypes.bool.isRequired,
+ isAlbumFolderMixed: PropTypes.bool.isRequired,
+ hasUnsearchedItems: PropTypes.bool.isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired,
+ onLookupPress: PropTypes.func.isRequired,
+ onCancelLookupPress: PropTypes.func.isRequired
+};
+
+export default ImportArtistFooter;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js
new file mode 100644
index 000000000..873d13b28
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js
@@ -0,0 +1,61 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import ImportArtistFooter from './ImportArtistFooter';
+import { lookupUnsearchedArtist, cancelLookupArtist } from 'Store/Actions/importArtistActions';
+
+function isMixed(items, selectedIds, defaultValue, key) {
+ return _.some(items, (artist) => {
+ return selectedIds.indexOf(artist.id) > -1 && artist[key] !== defaultValue;
+ });
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ (state, { selectedIds }) => selectedIds,
+ (addArtist, importArtist, selectedIds) => {
+ const {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ metadataProfileId: defaultMetadataProfileId,
+ albumFolder: defaultAlbumFolder
+ } = addArtist.defaults;
+
+ const {
+ isLookingUpArtist,
+ isImporting,
+ items
+ } = importArtist;
+
+ const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
+ const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
+ const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId');
+ const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder');
+ const hasUnsearchedItems = !isLookingUpArtist && items.some((item) => !item.isPopulated);
+
+ return {
+ selectedCount: selectedIds.length,
+ isLookingUpArtist,
+ isImporting,
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultMetadataProfileId,
+ defaultAlbumFolder,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isMetadataProfileIdMixed,
+ isAlbumFolderMixed,
+ hasUnsearchedItems
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ onLookupPress: lookupUnsearchedArtist,
+ onCancelLookupPress: cancelLookupArtist
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistFooter);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css
new file mode 100644
index 000000000..52b918403
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css
@@ -0,0 +1,38 @@
+.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,
+.metadataProfile {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.albumFolder {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 150px;
+ min-width: 120px;
+}
+
+.artist {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
+
+.detailsIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js
new file mode 100644
index 000000000..fb0a01cb7
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js
@@ -0,0 +1,96 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
+import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
+// import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent';
+import styles from './ImportArtistHeader.css';
+
+function ImportArtistHeader(props) {
+ const {
+ showMetadataProfile,
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ return (
+
+
+
+
+ Folder
+
+
+
+ Monitor
+
+
+ }
+ title="Monitoring Options"
+ body={ }
+ position={tooltipPositions.RIGHT}
+ />
+
+
+
+ Quality Profile
+
+
+ {
+ showMetadataProfile &&
+
+ Metadata Profile
+
+ }
+
+
+ Album Folder
+
+
+
+ Artist
+
+
+ );
+}
+
+ImportArtistHeader.propTypes = {
+ showMetadataProfile: PropTypes.bool.isRequired,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default ImportArtistHeader;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css
new file mode 100644
index 000000000..f5e6ed2e5
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css
@@ -0,0 +1,45 @@
+.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,
+.metadataProfile {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.albumFolder {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 150px;
+ min-width: 120px;
+}
+
+.artist {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
+
+.hideMetadataProfile {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: none;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js
new file mode 100644
index 000000000..ca3f32132
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js
@@ -0,0 +1,109 @@
+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 ImportArtistSelectArtistConnector from './SelectArtist/ImportArtistSelectArtistConnector';
+import styles from './ImportArtistRow.css';
+
+function ImportArtistRow(props) {
+ const {
+ style,
+ id,
+ monitor,
+ qualityProfileId,
+ metadataProfileId,
+ albumFolder,
+ selectedArtist,
+ isExistingArtist,
+ showMetadataProfile,
+ isSelected,
+ onSelectedChange,
+ onInputChange
+ } = props;
+
+ return (
+
+
+
+
+ {id}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+ImportArtistRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ metadataProfileId: PropTypes.number.isRequired,
+ albumFolder: PropTypes.bool.isRequired,
+ selectedArtist: PropTypes.object,
+ isExistingArtist: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+ImportArtistRow.defaultsProps = {
+ items: []
+};
+
+export default ImportArtistRow;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js
new file mode 100644
index 000000000..2480bfdb6
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.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 { setImportArtistValue } from 'Store/Actions/importArtistActions';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import ImportArtistRow from './ImportArtistRow';
+
+function createImportArtistItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.importArtist.items,
+ (id, items) => {
+ return _.find(items, { id }) || {};
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createImportArtistItemSelector(),
+ createAllArtistSelector(),
+ (item, artist) => {
+ const selectedArtist = item && item.selectedArtist;
+ const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId });
+
+ return {
+ ...item,
+ isExistingArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setImportArtistValue
+};
+
+class ImportArtistRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setImportArtistValue({
+ 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,
+ albumFolder
+ } = this.props;
+
+ if (!items || !monitor || !albumFolder == null) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+ImportArtistRowConnector.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string,
+ albumFolder: PropTypes.bool,
+ items: PropTypes.arrayOf(PropTypes.object),
+ setImportArtistValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRowConnector);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css
new file mode 100644
index 000000000..51fe4ce39
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css
@@ -0,0 +1,3 @@
+.input {
+ composes: input from '~Components/Form/CheckInput.css';
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js
new file mode 100644
index 000000000..f2c5f92eb
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js
@@ -0,0 +1,194 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import VirtualTable from 'Components/Table/VirtualTable';
+import ImportArtistHeader from './ImportArtistHeader';
+import ImportArtistRowConnector from './ImportArtistRowConnector';
+
+class ImportArtistTable extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ unmappedFolders,
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultMetadataProfileId,
+ defaultAlbumFolder,
+ onArtistLookup,
+ onSetImportArtistValue
+ } = this.props;
+
+ const values = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ metadataProfileId: defaultMetadataProfileId,
+ albumFolder: defaultAlbumFolder
+ };
+
+ unmappedFolders.forEach((unmappedFolder) => {
+ const id = unmappedFolder.name;
+
+ onArtistLookup(id, unmappedFolder.path);
+
+ onSetImportArtistValue({
+ 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 selectedArtist = item.selectedArtist;
+ const isSelected = selectedState[id];
+
+ const isExistingArtist = !!selectedArtist &&
+ _.some(prevProps.allArtists, { foreignArtistId: selectedArtist.foreignArtistId });
+
+ // Props doesn't have a selected artist or
+ // the selected artist is an existing artist.
+ if ((!selectedArtist && prevItem.selectedArtist) || (isExistingArtist && !prevItem.selectedArtist)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // State is selected, but a artist isn't selected or
+ // the selected artist is an existing artist.
+ if (isSelected && (!selectedArtist || isExistingArtist)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // A artist is being selected that wasn't previously selected.
+ if (selectedArtist && selectedArtist !== prevItem.selectedArtist) {
+ onSelectedChange({ id, value: true });
+
+ return;
+ }
+ });
+ }
+
+ //
+ // Control
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ rootFolderId,
+ items,
+ selectedState,
+ showMetadataProfile,
+ onSelectedChange
+ } = this.props;
+
+ const item = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ allSelected,
+ allUnselected,
+ isSmallScreen,
+ contentBody,
+ showMetadataProfile,
+ scrollTop,
+ selectedState,
+ onSelectAllChange,
+ onScroll
+ } = this.props;
+
+ if (!items.length) {
+ return null;
+ }
+
+ return (
+
+ }
+ selectedState={selectedState}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+ImportArtistTable.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object),
+ defaultMonitor: PropTypes.string.isRequired,
+ defaultQualityProfileId: PropTypes.number,
+ defaultMetadataProfileId: PropTypes.number,
+ defaultAlbumFolder: PropTypes.bool.isRequired,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ selectedState: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ allArtists: PropTypes.arrayOf(PropTypes.object),
+ contentBody: PropTypes.object.isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired,
+ onSelectedChange: PropTypes.func.isRequired,
+ onRemoveSelectedStateItem: PropTypes.func.isRequired,
+ onArtistLookup: PropTypes.func.isRequired,
+ onSetImportArtistValue: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ImportArtistTable;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js
new file mode 100644
index 000000000..fd7bf4fe2
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js
@@ -0,0 +1,43 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import ImportArtistTable from './ImportArtistTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ (state) => state.app.dimensions,
+ createAllArtistSelector(),
+ (addArtist, importArtist, dimensions, allArtists) => {
+ return {
+ defaultMonitor: addArtist.defaults.monitor,
+ defaultQualityProfileId: addArtist.defaults.qualityProfileId,
+ defaultMetadataProfileId: addArtist.defaults.metadataProfileId,
+ defaultAlbumFolder: addArtist.defaults.albumFolder,
+ items: importArtist.items,
+ isSmallScreen: dimensions.isSmallScreen,
+ allArtists
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onArtistLookup(name, path) {
+ dispatch(queueLookupArtist({
+ name,
+ path,
+ term: name
+ }));
+ },
+
+ onSetImportArtistValue(values) {
+ dispatch(setImportArtistValue(values));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ImportArtistTable);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css
new file mode 100644
index 000000000..fc86c41d1
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css
@@ -0,0 +1,19 @@
+.artistNameContainer {
+ display: flex;
+ align-items: center;
+ flex: 0 1 auto;
+ overflow: hidden;
+}
+
+.artistName {
+ @add-mixin truncate;
+}
+
+.disambiguation {
+ margin-right: 5px;
+ color: $disabledColor;
+}
+
+.existing {
+ margin-left: 5px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js
new file mode 100644
index 000000000..1d9fb21b7
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import styles from './ImportArtistName.css';
+
+function ImportArtistName(props) {
+ const {
+ artistName,
+ disambiguation,
+ isExistingArtist
+ } = props;
+
+ return (
+
+
+ {artistName}
+
+
+ {disambiguation}
+
+
+ {
+ isExistingArtist &&
+
+ Existing
+
+ }
+
+ );
+}
+
+ImportArtistName.propTypes = {
+ artistName: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ isExistingArtist: PropTypes.bool.isRequired
+};
+
+export default ImportArtistName;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css
new file mode 100644
index 000000000..f7bc065b5
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css
@@ -0,0 +1,8 @@
+.artist {
+ padding: 10px 20px;
+ width: 100%;
+
+ &:hover {
+ background-color: $menuItemHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js
new file mode 100644
index 000000000..aa489f0fb
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import ImportArtistName from './ImportArtistName';
+import styles from './ImportArtistSearchResult.css';
+
+class ImportArtistSearchResult extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.foreignArtistId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ disambiguation,
+ // year,
+ isExistingArtist
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+ImportArtistSearchResult.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ // year: PropTypes.number.isRequired,
+ isExistingArtist: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default ImportArtistSearchResult;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js
new file mode 100644
index 000000000..cdbcc03b3
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector';
+import ImportArtistSearchResult from './ImportArtistSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingArtistSelector(),
+ (isExistingArtist) => {
+ return {
+ isExistingArtist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ImportArtistSearchResult);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css
new file mode 100644
index 000000000..6bdfd093e
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css
@@ -0,0 +1,77 @@
+.button {
+ composes: link from '~Components/Link/Link.css';
+
+ display: flex;
+ align-items: center;
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+}
+
+.loading {
+ display: inline-block;
+}
+
+.warningIcon {
+ margin-right: 8px;
+}
+
+.existing {
+ margin-left: 5px;
+}
+
+.dropdownArrowContainer {
+ flex: 1 0 auto;
+ margin-left: 5px;
+ text-align: right;
+}
+
+.contentContainer {
+ z-index: $popperZIndex;
+ margin-top: 4px;
+ /* 400px container witdh with 8px padding on each side */
+ width: 384px;
+}
+
+.content {
+ padding: 4px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
+
+.searchContainer {
+ display: flex;
+}
+
+.searchIconContainer {
+ width: 58px;
+ border: 1px solid $inputBorderColor;
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: #edf1f2;
+ text-align: center;
+ line-height: 33px;
+}
+
+.searchInput {
+ composes: input from '~Components/Form/TextInput.css';
+
+ border-radius: 0;
+}
+
+.results {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+
+ overflow-x: hidden;
+ overflow-y: scroll;
+ max-height: 165px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js
new file mode 100644
index 000000000..68c448d1c
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js
@@ -0,0 +1,303 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Manager, Popper, Reference } from 'react-popper';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Portal from 'Components/Portal';
+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 ImportArtistSearchResultConnector from './ImportArtistSearchResultConnector';
+import ImportArtistName from './ImportArtistName';
+import styles from './ImportArtistSelectArtist.css';
+
+class ImportArtistSelectArtist extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._artistLookupTimeout = null;
+ this._scheduleUpdate = null;
+ this._buttonId = getUniqueElememtId();
+ this._contentId = getUniqueElememtId();
+
+ this.state = {
+ term: props.id,
+ isOpen: false
+ };
+ }
+
+ componentDidUpdate() {
+ if (this._scheduleUpdate) {
+ this._scheduleUpdate();
+ }
+ }
+
+ //
+ // Control
+
+ _addListener() {
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const button = document.getElementById(this._buttonId);
+ const content = document.getElementById(this._contentId);
+
+ if (!button || !content) {
+ return;
+ }
+
+ if (
+ !button.contains(event.target) &&
+ !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._artistLookupTimeout) {
+ clearTimeout(this._artistLookupTimeout);
+ }
+
+ this.setState({ term: value }, () => {
+ this._artistLookupTimeout = setTimeout(() => {
+ this.props.onSearchInputChange(value);
+ }, 200);
+ });
+ }
+
+ onRefreshPress = () => {
+ this.props.onSearchInputChange(this.state.term);
+ }
+
+ onArtistSelect = (foreignArtistId) => {
+ this.setState({ isOpen: false });
+
+ this.props.onArtistSelect(foreignArtistId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedArtist,
+ isExistingArtist,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isQueued,
+ isLookingUpArtist
+ } = this.props;
+
+ const errorMessage = error &&
+ error.responseJSON &&
+ error.responseJSON.message;
+
+ return (
+
+
+ {({ ref }) => (
+
+ )}
+
+
+
+
+ {({ ref, style, scheduleUpdate }) => {
+ this._scheduleUpdate = scheduleUpdate;
+
+ return (
+
+ {
+ this.state.isOpen ?
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+ null
+ }
+
+
+ );
+ }}
+
+
+
+ );
+ }
+}
+
+ImportArtistSelectArtist.propTypes = {
+ id: PropTypes.string.isRequired,
+ selectedArtist: PropTypes.object,
+ isExistingArtist: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isQueued: PropTypes.bool.isRequired,
+ isLookingUpArtist: PropTypes.bool.isRequired,
+ onSearchInputChange: PropTypes.func.isRequired,
+ onArtistSelect: PropTypes.func.isRequired
+};
+
+ImportArtistSelectArtist.defaultProps = {
+ isFetching: true,
+ isPopulated: false,
+ items: [],
+ isQueued: true
+};
+
+export default ImportArtistSelectArtist;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js
new file mode 100644
index 000000000..21e2bcab2
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.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 { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
+import createImportArtistItemSelector from 'Store/Selectors/createImportArtistItemSelector';
+import ImportArtistSelectArtist from './ImportArtistSelectArtist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.importArtist.isLookingUpArtist,
+ createImportArtistItemSelector(),
+ (isLookingUpArtist, item) => {
+ return {
+ isLookingUpArtist,
+ ...item
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ queueLookupArtist,
+ setImportArtistValue
+};
+
+class ImportArtistSelectArtistConnector extends Component {
+
+ //
+ // Listeners
+
+ onSearchInputChange = (term) => {
+ this.props.queueLookupArtist({
+ name: this.props.id,
+ term,
+ topOfQueue: true
+ });
+ }
+
+ onArtistSelect = (foreignArtistId) => {
+ const {
+ id,
+ items
+ } = this.props;
+
+ this.props.setImportArtistValue({
+ id,
+ selectedArtist: _.find(items, { foreignArtistId })
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportArtistSelectArtistConnector.propTypes = {
+ id: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ selectedArtist: PropTypes.object,
+ isSelected: PropTypes.bool,
+ queueLookupArtist: PropTypes.func.isRequired,
+ setImportArtistValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectArtistConnector);
diff --git a/frontend/src/AddArtist/ImportArtist/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/ImportArtist.js
new file mode 100644
index 000000000..ce5ec27ee
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/ImportArtist.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import ImportArtistSelectFolderConnector from 'AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector';
+import ImportArtistConnector from 'AddArtist/ImportArtist/Import/ImportArtistConnector';
+
+class ImportArtist extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default ImportArtist;
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css
new file mode 100644
index 000000000..030da96fb
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css
@@ -0,0 +1,32 @@
+.header {
+ margin-bottom: 40px;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.tips {
+ font-size: 20px;
+}
+
+.tip {
+ font-size: $defaultFontSize;
+}
+
+.code {
+ font-size: 12px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.recentFolders {
+ margin-top: 40px;
+}
+
+.startImport {
+ margin-top: 40px;
+ text-align: center;
+}
+
+.importButtonIcon {
+ margin-right: 8px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js
new file mode 100644
index 000000000..9a7253ec7
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js
@@ -0,0 +1,147 @@
+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 RootFolders from 'RootFolder/RootFolders';
+import styles from './ImportArtistSelectFolder.css';
+
+class ImportArtistSelectFolder 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 artist(s) you already have
+
+
+
+ Some tips to ensure the import goes smoothly:
+
+
+ Point Lidarr to the folder containing all of your music not a specific one. eg. "{isWindows ? 'C:\\music' : '/music'}" and not "{isWindows ? 'C:\\music\\sublime' : '/music/sublime'}"
+
+
+
+
+ {
+ items.length > 0 ?
+
+
+
+
+
+
+
+ Choose another folder
+
+ :
+
+
+
+
+ Start Import
+
+
+ }
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ImportArtistSelectFolder.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
+};
+
+export default ImportArtistSelectFolder;
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js
new file mode 100644
index 000000000..8354ed4da
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.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 { push } from 'connected-react-router';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { fetchRootFolders, addRootFolder } from 'Store/Actions/rootFolderActions';
+import ImportArtistSelectFolder from './ImportArtistSelectFolder';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ createSystemStatusSelector(),
+ (rootFolders, systemStatus) => {
+ return {
+ ...rootFolders,
+ isWindows: systemStatus.isWindows
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRootFolders,
+ addRootFolder,
+ push
+};
+
+class ImportArtistSelectFolderConnector 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.Lidarr.urlBase}/add/import/${newRootFolders[0].id}`);
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onNewRootFolderSelect = (path) => {
+ this.props.addRootFolder({ path });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportArtistSelectFolderConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchRootFolders: PropTypes.func.isRequired,
+ addRootFolder: PropTypes.func.isRequired,
+ push: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectFolderConnector);
diff --git a/frontend/src/Album/AlbumCover.js b/frontend/src/Album/AlbumCover.js
new file mode 100644
index 000000000..538fa5db8
--- /dev/null
+++ b/frontend/src/Album/AlbumCover.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ArtistImage from 'Artist/ArtistImage';
+
+const coverPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII=';
+
+function AlbumCover(props) {
+ return (
+
+ );
+}
+
+AlbumCover.propTypes = {
+ size: PropTypes.number.isRequired
+};
+
+AlbumCover.defaultProps = {
+ size: 250
+};
+
+export default AlbumCover;
diff --git a/frontend/src/Album/AlbumSearchCell.css b/frontend/src/Album/AlbumSearchCell.css
new file mode 100644
index 000000000..ba099e8c0
--- /dev/null
+++ b/frontend/src/Album/AlbumSearchCell.css
@@ -0,0 +1,6 @@
+.AlbumSearchCell {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+ white-space: nowrap;
+}
diff --git a/frontend/src/Album/AlbumSearchCell.js b/frontend/src/Album/AlbumSearchCell.js
new file mode 100644
index 000000000..9cd41f3f1
--- /dev/null
+++ b/frontend/src/Album/AlbumSearchCell.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import AlbumInteractiveSearchModalConnector from './Search/AlbumInteractiveSearchModalConnector';
+import styles from './AlbumSearchCell.css';
+
+class AlbumSearchCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onManualSearchPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ albumId,
+ albumTitle,
+ isSearching,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+AlbumSearchCell.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ artistId: PropTypes.number.isRequired,
+ albumTitle: PropTypes.string.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+export default AlbumSearchCell;
diff --git a/frontend/src/Album/AlbumSearchCellConnector.js b/frontend/src/Album/AlbumSearchCellConnector.js
new file mode 100644
index 000000000..2774db752
--- /dev/null
+++ b/frontend/src/Album/AlbumSearchCellConnector.js
@@ -0,0 +1,49 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import AlbumSearchCell from './AlbumSearchCell';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ createArtistSelector(),
+ createCommandsSelector(),
+ (albumId, artist, commands) => {
+ const isSearching = commands.some((command) => {
+ const albumSearch = command.name === commandNames.ALBUM_SEARCH;
+
+ if (!albumSearch) {
+ return false;
+ }
+
+ return (
+ isCommandExecuting(command) &&
+ command.body.albumIds.indexOf(albumId) > -1
+ );
+ });
+
+ return {
+ artistMonitored: artist.monitored,
+ artistType: artist.artistType,
+ isSearching
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSearchPress(name, path) {
+ dispatch(executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: [props.albumId]
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(AlbumSearchCell);
diff --git a/frontend/src/Album/AlbumTitleLink.css b/frontend/src/Album/AlbumTitleLink.css
new file mode 100644
index 000000000..47d897238
--- /dev/null
+++ b/frontend/src/Album/AlbumTitleLink.css
@@ -0,0 +1,8 @@
+.link {
+ composes: link from '~Components/Link/Link.css';
+
+ &:hover {
+ color: $linkHoverColor;
+ text-decoration: underline;
+ }
+}
diff --git a/frontend/src/Album/AlbumTitleLink.js b/frontend/src/Album/AlbumTitleLink.js
new file mode 100644
index 000000000..8b4dfe212
--- /dev/null
+++ b/frontend/src/Album/AlbumTitleLink.js
@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+
+function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) {
+ const link = `/album/${foreignAlbumId}`;
+
+ return (
+
+ {title}{disambiguation ? ` (${disambiguation})` : ''}
+
+ );
+}
+
+AlbumTitleLink.propTypes = {
+ foreignAlbumId: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string
+};
+
+export default AlbumTitleLink;
diff --git a/frontend/src/Album/Details/AlbumDetails.css b/frontend/src/Album/Details/AlbumDetails.css
new file mode 100644
index 000000000..1e590835a
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetails.css
@@ -0,0 +1,153 @@
+.innerContentBody {
+ padding: 0;
+}
+
+.header {
+ position: relative;
+ width: 100%;
+ height: 310px;
+}
+
+.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;
+}
+
+.cover {
+ flex-shrink: 0;
+ margin-right: 35px;
+ width: 250px;
+ height: 250px;
+}
+
+.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;
+}
+
+.albumNavigationButtons {
+ white-space: nowrap;
+}
+
+.albumNavigationButton {
+ 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;
+}
+
+.duration {
+ margin-right: 15px;
+}
+
+.detailsLabel {
+ composes: label from '~Components/Label.css';
+
+ margin: 5px 10px 5px 0;
+}
+
+.sizeOnDisk,
+.qualityProfileName,
+.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) {
+ .cover {
+ display: none;
+ }
+}
diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js
new file mode 100644
index 000000000..f2288b00f
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetails.js
@@ -0,0 +1,596 @@
+import _ from 'lodash';
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
+import fonts from 'Styles/Variables/fonts';
+import HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import AlbumCover from 'Album/AlbumCover';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
+import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector';
+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 AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector';
+import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
+import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector';
+import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
+import AlbumDetailsLinks from './AlbumDetailsLinks';
+import styles from './AlbumDetails.css';
+
+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 formatDuration(timeSpan) {
+ const duration = moment.duration(timeSpan);
+ const hours = duration.get('hours');
+ const minutes = duration.get('minutes');
+ let hoursText = 'Hours';
+ let minText = 'Minutes';
+
+ if (minutes === 1) {
+ minText = 'Minute';
+ }
+
+ if (hours === 0) {
+ return `${minutes} ${minText}`;
+ }
+
+ if (hours === 1) {
+ hoursText = 'Hour';
+ }
+
+ return `${hours} ${hoursText} ${minutes} ${minText}`;
+}
+
+function getExpandedState(newState) {
+ return {
+ allExpanded: newState.allSelected,
+ allCollapsed: newState.allUnselected,
+ expandedState: newState.selectedState
+ };
+}
+
+class AlbumDetails extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isRetagModalOpen: false,
+ isArtistHistoryModalOpen: false,
+ isInteractiveSearchModalOpen: false,
+ isManageTracksOpen: false,
+ isEditAlbumModalOpen: false,
+ allExpanded: false,
+ allCollapsed: false,
+ expandedState: {}
+ };
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onRetagPress = () => {
+ this.setState({ isRetagModalOpen: true });
+ }
+
+ onRetagModalClose = () => {
+ this.setState({ isRetagModalOpen: false });
+ }
+
+ onEditAlbumPress = () => {
+ this.setState({ isEditAlbumModalOpen: true });
+ }
+
+ onEditAlbumModalClose = () => {
+ this.setState({ isEditAlbumModalOpen: false });
+ }
+
+ onManageTracksPress = () => {
+ this.setState({ isManageTracksOpen: true });
+ }
+
+ onManageTracksModalClose = () => {
+ this.setState({ isManageTracksOpen: false });
+ }
+
+ onInteractiveSearchPress = () => {
+ this.setState({ isInteractiveSearchModalOpen: true });
+ }
+
+ onInteractiveSearchModalClose = () => {
+ this.setState({ isInteractiveSearchModalOpen: false });
+ }
+
+ onArtistHistoryPress = () => {
+ this.setState({ isArtistHistoryModalOpen: true });
+ }
+
+ onArtistHistoryModalClose = () => {
+ this.setState({ isArtistHistoryModalOpen: false });
+ }
+
+ onExpandAllPress = () => {
+ const {
+ allExpanded,
+ expandedState
+ } = this.state;
+
+ this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
+ }
+
+ onExpandPress = (albumId, isExpanded) => {
+ this.setState((state) => {
+ const convertedState = {
+ allSelected: state.allExpanded,
+ allUnselected: state.allCollapsed,
+ selectedState: state.expandedState
+ };
+
+ const newState = toggleSelected(convertedState, [], albumId, isExpanded, false);
+
+ return getExpandedState(newState);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ foreignAlbumId,
+ title,
+ disambiguation,
+ duration,
+ overview,
+ albumType,
+ statistics = {},
+ monitored,
+ releaseDate,
+ ratings,
+ images,
+ links,
+ media,
+ isSaving,
+ isFetching,
+ isPopulated,
+ albumsError,
+ trackFilesError,
+ hasTrackFiles,
+ shortDateFormat,
+ artist,
+ previousAlbum,
+ nextAlbum,
+ isSearching,
+ onMonitorTogglePress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ isOrganizeModalOpen,
+ isRetagModalOpen,
+ isArtistHistoryModalOpen,
+ isInteractiveSearchModalOpen,
+ isEditAlbumModalOpen,
+ isManageTracksOpen,
+ allExpanded,
+ allCollapsed,
+ expandedState
+ } = this.state;
+
+ let expandIcon = icons.EXPAND_INDETERMINATE;
+
+ if (allExpanded) {
+ expandIcon = icons.COLLAPSE;
+ } else if (allCollapsed) {
+ expandIcon = icons.EXPAND;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}{disambiguation ? ` (${disambiguation})` : ''}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !!duration &&
+
+ {formatDuration(duration)}
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ moment(releaseDate).format(shortDateFormat)
+ }
+
+
+
+
+
+
+
+ {
+ formatBytes(statistics.sizeOnDisk)
+ }
+
+
+
+
+
+
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+
+
+ {
+ !!albumType &&
+
+
+
+
+ {albumType}
+
+
+ }
+
+
+
+
+
+ Links
+
+
+ }
+ tooltip={
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+
+
+
+
+
+
+
+ {
+ !isPopulated && !albumsError && !trackFilesError &&
+
+ }
+
+ {
+ !isFetching && albumsError &&
+
Loading albums failed
+ }
+
+ {
+ !isFetching && trackFilesError &&
+
Loading track files failed
+ }
+
+ {
+ isPopulated && !!media.length &&
+
+
+ {
+ media.slice(0).map((medium) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+AlbumDetails.propTypes = {
+ id: PropTypes.number.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ duration: PropTypes.number,
+ overview: PropTypes.string,
+ albumType: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ ratings: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ links: PropTypes.arrayOf(PropTypes.object).isRequired,
+ media: PropTypes.arrayOf(PropTypes.object).isRequired,
+ monitored: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ isSearching: PropTypes.bool,
+ isFetching: PropTypes.bool,
+ isPopulated: PropTypes.bool,
+ albumsError: PropTypes.object,
+ tracksError: PropTypes.object,
+ trackFilesError: PropTypes.object,
+ hasTrackFiles: PropTypes.bool.isRequired,
+ artist: PropTypes.object,
+ previousAlbum: PropTypes.object,
+ nextAlbum: PropTypes.object,
+ onMonitorTogglePress: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+AlbumDetails.defaultProps = {
+ isSaving: false
+};
+
+export default AlbumDetails;
diff --git a/frontend/src/Album/Details/AlbumDetailsConnector.js b/frontend/src/Album/Details/AlbumDetailsConnector.js
new file mode 100644
index 000000000..3bcbfd06b
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetailsConnector.js
@@ -0,0 +1,184 @@
+/* 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 { findCommand } from 'Utilities/Command';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { toggleAlbumsMonitored } from 'Store/Actions/albumActions';
+import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
+import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import AlbumDetails from './AlbumDetails';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+
+const selectTrackFiles = createSelector(
+ (state) => state.trackFiles,
+ (trackFiles) => {
+ const {
+ items,
+ isFetching,
+ isPopulated,
+ error
+ } = trackFiles;
+
+ const hasTrackFiles = !!items.length;
+
+ return {
+ isTrackFilesFetching: isFetching,
+ isTrackFilesPopulated: isPopulated,
+ trackFilesError: error,
+ hasTrackFiles
+ };
+ }
+);
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { foreignAlbumId }) => foreignAlbumId,
+ (state) => state.tracks,
+ selectTrackFiles,
+ (state) => state.albums,
+ createAllArtistSelector(),
+ createCommandsSelector(),
+ createUISettingsSelector(),
+ (foreignAlbumId, tracks, trackFiles, albums, artists, commands, uiSettings) => {
+ const sortedAlbums = _.orderBy(albums.items, 'releaseDate');
+ const albumIndex = _.findIndex(sortedAlbums, { foreignAlbumId });
+ const album = sortedAlbums[albumIndex];
+ const artist = _.find(artists, { id: album.artistId });
+
+ if (!album) {
+ return {};
+ }
+
+ const {
+ isTrackFilesFetching,
+ isTrackFilesPopulated,
+ trackFilesError,
+ hasTrackFiles
+ } = trackFiles;
+
+ const previousAlbum = sortedAlbums[albumIndex - 1] || _.last(sortedAlbums);
+ const nextAlbum = sortedAlbums[albumIndex + 1] || _.first(sortedAlbums);
+ const isSearching = !!findCommand(commands, { name: commandNames.ALBUM_SEARCH });
+
+ const isFetching = tracks.isFetching || isTrackFilesFetching;
+ const isPopulated = tracks.isPopulated && isTrackFilesPopulated;
+ const tracksError = tracks.error;
+
+ return {
+ ...album,
+ shortDateFormat: uiSettings.shortDateFormat,
+ artist,
+ isSearching,
+ isFetching,
+ isPopulated,
+ tracksError,
+ trackFilesError,
+ hasTrackFiles,
+ previousAlbum,
+ nextAlbum
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand,
+ fetchTracks,
+ clearTracks,
+ fetchTrackFiles,
+ clearTrackFiles,
+ toggleAlbumsMonitored
+};
+
+function getMonitoredReleases(props) {
+ return _.map(_.filter(props.releases, { monitored: true }), 'id').sort();
+}
+
+class AlbumDetailsConnector extends Component {
+
+ componentDidMount() {
+ registerPagePopulator(this.populate);
+ this.populate();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) ||
+ (prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) {
+ this.unpopulate();
+ this.populate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.populate);
+ this.unpopulate();
+ }
+
+ //
+ // Control
+
+ populate = () => {
+ const albumId = this.props.id;
+
+ this.props.fetchTracks({ albumId });
+ this.props.fetchTrackFiles({ albumId });
+ }
+
+ unpopulate = () => {
+ this.props.clearTracks();
+ this.props.clearTrackFiles();
+ }
+
+ //
+ // Listeners
+
+ onMonitorTogglePress = (monitored) => {
+ this.props.toggleAlbumsMonitored({
+ albumIds: [this.props.id],
+ monitored
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: [this.props.id]
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AlbumDetailsConnector.propTypes = {
+ id: PropTypes.number,
+ anyReleaseOk: PropTypes.bool,
+ isAlbumFetching: PropTypes.bool,
+ isAlbumPopulated: PropTypes.bool,
+ foreignAlbumId: PropTypes.string.isRequired,
+ fetchTracks: PropTypes.func.isRequired,
+ clearTracks: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired,
+ toggleAlbumsMonitored: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsConnector);
diff --git a/frontend/src/Album/Details/AlbumDetailsLinks.css b/frontend/src/Album/Details/AlbumDetailsLinks.css
new file mode 100644
index 000000000..d37a082a1
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetailsLinks.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/Album/Details/AlbumDetailsLinks.js b/frontend/src/Album/Details/AlbumDetailsLinks.js
new file mode 100644
index 000000000..265a7c4ff
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetailsLinks.js
@@ -0,0 +1,63 @@
+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 './AlbumDetailsLinks.css';
+
+function AlbumDetailsLinks(props) {
+ const {
+ foreignAlbumId,
+ links
+ } = props;
+
+ return (
+
+
+
+
+ Musicbrainz
+
+
+
+ {links.map((link, index) => {
+ return (
+
+
+
+ {link.name}
+
+
+ {(index > 0 && index % 5 === 0) &&
+
+ }
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+AlbumDetailsLinks.propTypes = {
+ foreignAlbumId: PropTypes.string.isRequired,
+ links: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default AlbumDetailsLinks;
diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.css b/frontend/src/Album/Details/AlbumDetailsMedium.css
new file mode 100644
index 000000000..67418316d
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetailsMedium.css
@@ -0,0 +1,114 @@
+.medium {
+ margin-bottom: 20px;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-size: 24px;
+}
+
+.mediumNumber {
+ margin-right: 10px;
+ margin-left: 5px;
+}
+
+.mediumFormat {
+ color: #8895aa;
+ font-style: italic;
+ font-size: 18px;
+}
+
+.expandButton {
+ composes: link from '~Components/Link/Link.css';
+
+ flex-grow: 1;
+ margin: 0 20px;
+ text-align: center;
+}
+
+.left {
+ display: flex;
+ align-items: center;
+ flex: 0 1 300px;
+}
+
+.left,
+.actions {
+ padding: 15px 10px;
+}
+
+.actionsMenu {
+ composes: menu from '~Components/Menu/Menu.css';
+
+ flex: 0 0 45px;
+}
+
+.actionsMenuContent {
+ composes: menuContent from '~Components/Menu/MenuContent.css';
+
+ white-space: nowrap;
+ font-size: 14px;
+}
+
+.actionMenuIcon {
+ margin-right: 8px;
+}
+
+.actionButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ width: 30px;
+}
+
+.tracks {
+ padding-top: 15px;
+ border-top: 1px solid $borderColor;
+}
+
+.collapseButtonContainer {
+ padding: 10px 15px;
+ width: 100%;
+ border-top: 1px solid $borderColor;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ background-color: #fafafa;
+ text-align: center;
+}
+
+.expandButtonIcon {
+ composes: actionButton;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -12px;
+ margin-left: -15px;
+}
+
+.noTracks {
+ margin-bottom: 15px;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .medium {
+ border-right: 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+
+ .expandButtonIcon {
+ position: static;
+ margin: 0;
+ }
+}
diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.js b/frontend/src/Album/Details/AlbumDetailsMedium.js
new file mode 100644
index 000000000..33d6efb80
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetailsMedium.js
@@ -0,0 +1,210 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TrackRowConnector from './TrackRowConnector';
+import styles from './AlbumDetailsMedium.css';
+
+function getMediumStatistics(tracks) {
+ let trackCount = 0;
+ let trackFileCount = 0;
+ let totalTrackCount = 0;
+
+ tracks.forEach((track) => {
+ if (track.trackFileId) {
+ trackCount++;
+ trackFileCount++;
+ } else {
+ trackCount++;
+ }
+
+ totalTrackCount++;
+ });
+
+ return {
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ };
+}
+
+function getTrackCountKind(monitored, trackFileCount, trackCount) {
+ if (trackFileCount === trackCount && trackCount > 0) {
+ return kinds.SUCCESS;
+ }
+
+ if (!monitored) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+class AlbumDetailsMedium extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this._expandByDefault();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.albumId !== this.props.albumId) {
+ this._expandByDefault();
+ }
+ }
+
+ //
+ // Control
+
+ _expandByDefault() {
+ const {
+ mediumNumber,
+ onExpandPress
+ } = this.props;
+
+ onExpandPress(mediumNumber, mediumNumber === 1);
+ }
+
+ //
+ // Listeners
+
+ onExpandPress = () => {
+ const {
+ mediumNumber,
+ isExpanded
+ } = this.props;
+
+ this.props.onExpandPress(mediumNumber, !isExpanded);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ mediumNumber,
+ mediumFormat,
+ albumMonitored,
+ items,
+ columns,
+ onTableOptionChange,
+ isExpanded,
+ isSmallScreen
+ } = this.props;
+
+ const {
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ } = getMediumStatistics(items);
+
+ return (
+
+
+
+ {
+
+
+ {mediumFormat} {mediumNumber}
+
+
+ }
+
+
+ {
+ {trackFileCount} / {trackCount}
+ }
+
+
+
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+
+
+
+ {
+ isExpanded &&
+
+ {
+ items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+
+
+ No tracks in this medium
+
+ }
+
+
+
+
+ }
+
+
+ );
+ }
+}
+
+AlbumDetailsMedium.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ albumMonitored: PropTypes.bool.isRequired,
+ mediumNumber: PropTypes.number.isRequired,
+ mediumFormat: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool,
+ isExpanded: PropTypes.bool,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onExpandPress: PropTypes.func.isRequired
+};
+
+export default AlbumDetailsMedium;
diff --git a/frontend/src/Album/Details/AlbumDetailsMediumConnector.js b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js
new file mode 100644
index 000000000..e05d9870d
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js
@@ -0,0 +1,65 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { setTracksTableOption } from 'Store/Actions/trackActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import AlbumDetailsMedium from './AlbumDetailsMedium';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { mediumNumber }) => mediumNumber,
+ (state) => state.tracks,
+ createDimensionsSelector(),
+ (mediumNumber, tracks, dimensions) => {
+
+ const tracksInMedium = _.filter(tracks.items, { mediumNumber });
+ const sortedTracks = _.orderBy(tracksInMedium, ['absoluteTrackNumber'], ['asc']);
+
+ return {
+ items: sortedTracks,
+ columns: tracks.columns,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setTracksTableOption,
+ executeCommand
+};
+
+class AlbumDetailsMediumConnector extends Component {
+
+ //
+ // Listeners
+
+ onTableOptionChange = (payload) => {
+ this.props.setTracksTableOption(payload);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AlbumDetailsMediumConnector.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ albumMonitored: PropTypes.bool.isRequired,
+ mediumNumber: PropTypes.number.isRequired,
+ setTracksTableOption: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsMediumConnector);
diff --git a/frontend/src/Album/Details/AlbumDetailsPageConnector.js b/frontend/src/Album/Details/AlbumDetailsPageConnector.js
new file mode 100644
index 000000000..fffd014ad
--- /dev/null
+++ b/frontend/src/Album/Details/AlbumDetailsPageConnector.js
@@ -0,0 +1,120 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { push } from 'connected-react-router';
+import NotFound from 'Components/NotFound';
+import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import AlbumDetailsConnector from './AlbumDetailsConnector';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ (state) => state.albums,
+ (state) => state.artist,
+ (match, albums, artist) => {
+ const foreignAlbumId = match.params.foreignAlbumId;
+ const isFetching = albums.isFetching || artist.isFetching;
+ const isPopulated = albums.isPopulated && artist.isPopulated;
+
+ return {
+ foreignAlbumId,
+ isFetching,
+ isPopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ push,
+ fetchAlbums,
+ clearAlbums
+};
+
+class AlbumDetailsPageConnector extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = { hasMounted: false };
+ }
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.populate();
+ }
+
+ componentWillUnmount() {
+ this.unpopulate();
+ }
+
+ //
+ // Control
+
+ populate = () => {
+ const foreignAlbumId = this.props.foreignAlbumId;
+ this.setState({ hasMounted: true });
+ this.props.fetchAlbums({
+ foreignAlbumId,
+ includeAllArtistAlbums: true
+ });
+ }
+
+ unpopulate = () => {
+ this.props.clearAlbums();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ foreignAlbumId,
+ isFetching,
+ isPopulated
+ } = this.props;
+
+ if (!foreignAlbumId) {
+ return (
+
+ );
+ }
+
+ if ((isFetching || !this.state.hasMounted) ||
+ (!isFetching && !isPopulated)) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!isFetching && isPopulated && this.state.hasMounted) {
+ return (
+
+ );
+ }
+ }
+}
+
+AlbumDetailsPageConnector.propTypes = {
+ foreignAlbumId: PropTypes.string,
+ match: PropTypes.shape({ params: PropTypes.shape({ foreignAlbumId: PropTypes.string.isRequired }).isRequired }).isRequired,
+ push: PropTypes.func.isRequired,
+ fetchAlbums: PropTypes.func.isRequired,
+ clearAlbums: PropTypes.func.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsPageConnector);
diff --git a/frontend/src/Album/Details/TrackActionsCell.css b/frontend/src/Album/Details/TrackActionsCell.css
new file mode 100644
index 000000000..6b80ba0e0
--- /dev/null
+++ b/frontend/src/Album/Details/TrackActionsCell.css
@@ -0,0 +1,6 @@
+.TrackActionsCell {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+ white-space: nowrap;
+}
diff --git a/frontend/src/Album/Details/TrackActionsCell.js b/frontend/src/Album/Details/TrackActionsCell.js
new file mode 100644
index 000000000..db73b35b7
--- /dev/null
+++ b/frontend/src/Album/Details/TrackActionsCell.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import FileDetailsModal from 'TrackFile/FileDetailsModal';
+import styles from './TrackActionsCell.css';
+
+class TrackActionsCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false,
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ onDeleteFilePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ }
+
+ onConfirmDelete = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ this.props.deleteTrackFile({ id: this.props.trackFileId });
+ }
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+
+ const {
+ trackFileId,
+ trackFilePath
+ } = this.props;
+
+ const {
+ isDetailsModalOpen,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ return (
+
+ {
+ trackFilePath &&
+
+ }
+ {
+ trackFilePath &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+TrackActionsCell.propTypes = {
+ id: PropTypes.number.isRequired,
+ albumId: PropTypes.number.isRequired,
+ trackFilePath: PropTypes.string,
+ trackFileId: PropTypes.number.isRequired,
+ deleteTrackFile: PropTypes.func.isRequired
+};
+
+export default TrackActionsCell;
diff --git a/frontend/src/Album/Details/TrackRow.css b/frontend/src/Album/Details/TrackRow.css
new file mode 100644
index 000000000..c77d215f2
--- /dev/null
+++ b/frontend/src/Album/Details/TrackRow.css
@@ -0,0 +1,30 @@
+.title {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
+
+.monitored {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 42px;
+}
+
+.trackNumber {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.audio {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 250px;
+}
+
+.duration,
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js
new file mode 100644
index 000000000..217215f5c
--- /dev/null
+++ b/frontend/src/Album/Details/TrackRow.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
+import MediaInfoConnector from 'TrackFile/MediaInfoConnector';
+import TrackActionsCell from './TrackActionsCell';
+import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes';
+
+import styles from './TrackRow.css';
+
+class TrackRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ albumId,
+ mediumNumber,
+ trackFileId,
+ absoluteTrackNumber,
+ title,
+ duration,
+ trackFilePath,
+ trackFileRelativePath,
+ columns,
+ deleteTrackFile
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'medium') {
+ return (
+
+ {mediumNumber}
+
+ );
+ }
+
+ if (name === 'absoluteTrackNumber') {
+ return (
+
+ {absoluteTrackNumber}
+
+ );
+ }
+
+ if (name === 'title') {
+ return (
+
+ {title}
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {
+ trackFilePath
+ }
+
+ );
+ }
+
+ if (name === 'relativePath') {
+ return (
+
+ {
+ trackFileRelativePath
+ }
+
+ );
+ }
+
+ if (name === 'duration') {
+ return (
+
+ {
+ formatTimeSpan(duration)
+ }
+
+ );
+ }
+
+ if (name === 'audioInfo') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+ }
+}
+
+TrackRow.propTypes = {
+ deleteTrackFile: PropTypes.func.isRequired,
+ id: PropTypes.number.isRequired,
+ albumId: PropTypes.number.isRequired,
+ trackFileId: PropTypes.number,
+ mediumNumber: PropTypes.number.isRequired,
+ trackNumber: PropTypes.string.isRequired,
+ absoluteTrackNumber: PropTypes.number,
+ title: PropTypes.string.isRequired,
+ duration: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool,
+ trackFilePath: PropTypes.string,
+ trackFileRelativePath: PropTypes.string,
+ mediaInfo: PropTypes.object,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default TrackRow;
diff --git a/frontend/src/Album/Details/TrackRowConnector.js b/frontend/src/Album/Details/TrackRowConnector.js
new file mode 100644
index 000000000..8074c7b61
--- /dev/null
+++ b/frontend/src/Album/Details/TrackRowConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import { deleteTrackFile } from 'Store/Actions/trackFileActions';
+import TrackRow from './TrackRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ createTrackFileSelector(),
+ (id, trackFile) => {
+ return {
+ trackFilePath: trackFile ? trackFile.path : null,
+ trackFileRelativePath: trackFile ? trackFile.relativePath : null
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteTrackFile
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow);
diff --git a/frontend/src/Album/Edit/EditAlbumModal.js b/frontend/src/Album/Edit/EditAlbumModal.js
new file mode 100644
index 000000000..d47bb284f
--- /dev/null
+++ b/frontend/src/Album/Edit/EditAlbumModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditAlbumModalContentConnector from './EditAlbumModalContentConnector';
+
+function EditAlbumModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditAlbumModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditAlbumModal;
diff --git a/frontend/src/Album/Edit/EditAlbumModalConnector.js b/frontend/src/Album/Edit/EditAlbumModalConnector.js
new file mode 100644
index 000000000..7c2383f0f
--- /dev/null
+++ b/frontend/src/Album/Edit/EditAlbumModalConnector.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 EditAlbumModal from './EditAlbumModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditAlbumModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'albums' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditAlbumModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(EditAlbumModalConnector);
diff --git a/frontend/src/Album/Edit/EditAlbumModalContent.js b/frontend/src/Album/Edit/EditAlbumModalContent.js
new file mode 100644
index 000000000..949feee08
--- /dev/null
+++ b/frontend/src/Album/Edit/EditAlbumModalContent.js
@@ -0,0 +1,133 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } 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';
+
+class EditAlbumModalContent extends Component {
+
+ //
+ // Listeners
+
+ onSavePress = () => {
+ const {
+ onSavePress
+ } = this.props;
+
+ onSavePress(false);
+
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ artistName,
+ albumType,
+ statistics,
+ item,
+ isSaving,
+ onInputChange,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const {
+ monitored,
+ anyReleaseOk,
+ releases
+ } = item;
+
+ return (
+
+
+ Edit - {artistName} - {title} [{albumType}]
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+ );
+ }
+}
+
+EditAlbumModalContent.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ item: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditAlbumModalContent;
diff --git a/frontend/src/Album/Edit/EditAlbumModalContentConnector.js b/frontend/src/Album/Edit/EditAlbumModalContentConnector.js
new file mode 100644
index 000000000..f6329f8e8
--- /dev/null
+++ b/frontend/src/Album/Edit/EditAlbumModalContentConnector.js
@@ -0,0 +1,98 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import createAlbumSelector from 'Store/Selectors/createAlbumSelector';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { setAlbumValue, saveAlbum } from 'Store/Actions/albumActions';
+import EditAlbumModalContent from './EditAlbumModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.albums,
+ createAlbumSelector(),
+ createArtistSelector(),
+ (albumState, album, artist) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges
+ } = albumState;
+
+ const albumSettings = _.pick(album, [
+ 'monitored',
+ 'anyReleaseOk',
+ 'releases'
+ ]);
+
+ const settings = selectSettings(albumSettings, pendingChanges, saveError);
+
+ return {
+ title: album.title,
+ artistName: artist.artistName,
+ albumType: album.albumType,
+ statistics: album.statistics,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetAlbumValue: setAlbumValue,
+ dispatchSaveAlbum: saveAlbum
+};
+
+class EditAlbumModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetAlbumValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.dispatchSaveAlbum({
+ id: this.props.albumId
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditAlbumModalContentConnector.propTypes = {
+ albumId: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ dispatchSetAlbumValue: PropTypes.func.isRequired,
+ dispatchSaveAlbum: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditAlbumModalContentConnector);
diff --git a/frontend/src/Album/EpisodeNumber.css b/frontend/src/Album/EpisodeNumber.css
new file mode 100644
index 000000000..1c5072d02
--- /dev/null
+++ b/frontend/src/Album/EpisodeNumber.css
@@ -0,0 +1,7 @@
+.absoluteEpisodeNumber {
+ margin-left: 5px;
+}
+
+.warning {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Album/EpisodeNumber.js b/frontend/src/Album/EpisodeNumber.js
new file mode 100644
index 000000000..73e105376
--- /dev/null
+++ b/frontend/src/Album/EpisodeNumber.js
@@ -0,0 +1,107 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+import SceneInfo from './SceneInfo';
+import styles from './EpisodeNumber.css';
+
+function EpisodeNumber(props) {
+ const {
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ unverifiedSceneNumbering,
+ alternateTitles,
+ artistType
+ } = props;
+
+ const hasSceneInformation = sceneSeasonNumber !== undefined ||
+ sceneEpisodeNumber !== undefined ||
+ (artistType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) ||
+ !!alternateTitles.length;
+
+ return (
+
+ {
+ hasSceneInformation ?
+
+ {episodeNumber}
+
+ {
+ artistType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ }
+ title="Scene Information"
+ body={
+
+ }
+ position={tooltipPositions.RIGHT}
+ /> :
+
+ {episodeNumber}
+
+ {
+ artistType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ }
+
+ {
+ unverifiedSceneNumbering &&
+
+ }
+
+ {
+ artistType === 'anime' && !absoluteEpisodeNumber &&
+
+ }
+
+ );
+}
+
+EpisodeNumber.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ unverifiedSceneNumbering: PropTypes.bool.isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ artistType: PropTypes.string
+};
+
+EpisodeNumber.defaultProps = {
+ unverifiedSceneNumbering: false,
+ alternateTitles: []
+};
+
+export default EpisodeNumber;
diff --git a/frontend/src/Album/EpisodeStatus.css b/frontend/src/Album/EpisodeStatus.css
new file mode 100644
index 000000000..3833887df
--- /dev/null
+++ b/frontend/src/Album/EpisodeStatus.css
@@ -0,0 +1,4 @@
+.center {
+ display: flex;
+ justify-content: center;
+}
diff --git a/frontend/src/Album/EpisodeStatus.js b/frontend/src/Album/EpisodeStatus.js
new file mode 100644
index 000000000..a2c792752
--- /dev/null
+++ b/frontend/src/Album/EpisodeStatus.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import isBefore from 'Utilities/Date/isBefore';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import ProgressBar from 'Components/ProgressBar';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+import TrackQuality from './TrackQuality';
+import styles from './EpisodeStatus.css';
+
+function EpisodeStatus(props) {
+ const {
+ airDateUtc,
+ monitored,
+ grabbed,
+ queueItem,
+ trackFile
+ } = props;
+
+ const hasTrackFile = !!trackFile;
+ const isQueued = !!queueItem;
+ const hasAired = isBefore(airDateUtc);
+
+ if (isQueued) {
+ const {
+ sizeleft,
+ size
+ } = queueItem;
+
+ const progress = (100 - sizeleft / size * 100);
+
+ return (
+
+
+ }
+ />
+
+ );
+ }
+
+ if (grabbed) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasTrackFile) {
+ const quality = trackFile.quality;
+ const isCutoffNotMet = trackFile.qualityCutoffNotMet;
+
+ return (
+
+
+
+ );
+ }
+
+ if (!airDateUtc) {
+ return (
+
+
+
+ );
+ }
+
+ if (!monitored) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasAired) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+EpisodeStatus.propTypes = {
+ airDateUtc: PropTypes.string,
+ monitored: PropTypes.bool,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ trackFile: PropTypes.object
+};
+
+export default EpisodeStatus;
diff --git a/frontend/src/Album/EpisodeStatusConnector.js b/frontend/src/Album/EpisodeStatusConnector.js
new file mode 100644
index 000000000..f3a390748
--- /dev/null
+++ b/frontend/src/Album/EpisodeStatusConnector.js
@@ -0,0 +1,53 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAlbumSelector from 'Store/Selectors/createAlbumSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import EpisodeStatus from './EpisodeStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ createAlbumSelector(),
+ createQueueItemSelector(),
+ createTrackFileSelector(),
+ (album, queueItem, trackFile) => {
+ const result = _.pick(album, [
+ 'airDateUtc',
+ 'monitored',
+ 'grabbed'
+ ]);
+
+ result.queueItem = queueItem;
+ result.trackFile = trackFile;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+};
+
+class EpisodeStatusConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EpisodeStatusConnector.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ trackFileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);
diff --git a/frontend/src/Album/SceneInfo.css b/frontend/src/Album/SceneInfo.css
new file mode 100644
index 000000000..8a5f4bccd
--- /dev/null
+++ b/frontend/src/Album/SceneInfo.css
@@ -0,0 +1,17 @@
+.descriptionList {
+ composes: descriptionList from '~Components/DescriptionList/DescriptionList.css';
+
+ margin-right: 10px;
+}
+
+.title {
+ composes: title from '~Components/DescriptionList/DescriptionListItemTitle.css';
+
+ width: 80px;
+}
+
+.description {
+ composes: title from '~Components/DescriptionList/DescriptionListItemDescription.css';
+
+ margin-left: 100px;
+}
diff --git a/frontend/src/Album/SceneInfo.js b/frontend/src/Album/SceneInfo.js
new file mode 100644
index 000000000..ed171248a
--- /dev/null
+++ b/frontend/src/Album/SceneInfo.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './SceneInfo.css';
+
+function SceneInfo(props) {
+ const {
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ alternateTitles,
+ artistType
+ } = props;
+
+ return (
+
+ {
+ sceneSeasonNumber !== undefined &&
+
+ }
+
+ {
+ sceneEpisodeNumber !== undefined &&
+
+ }
+
+ {
+ artistType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
+
+ }
+
+ {
+ !!alternateTitles.length &&
+
+ {
+ alternateTitles.map((alternateTitle) => {
+ return (
+
+ {alternateTitle.title}
+
+ );
+ })
+ }
+
+ }
+ />
+ }
+
+ );
+}
+
+SceneInfo.propTypes = {
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ artistType: PropTypes.string
+};
+
+export default SceneInfo;
diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModal.js b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js
new file mode 100644
index 000000000..52e825bab
--- /dev/null
+++ b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AlbumInteractiveSearchModalContent from './AlbumInteractiveSearchModalContent';
+
+function AlbumInteractiveSearchModal(props) {
+ const {
+ isOpen,
+ albumId,
+ albumTitle,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AlbumInteractiveSearchModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ albumId: PropTypes.number.isRequired,
+ albumTitle: PropTypes.string.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AlbumInteractiveSearchModal;
diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModalConnector.js b/frontend/src/Album/Search/AlbumInteractiveSearchModalConnector.js
new file mode 100644
index 000000000..5b23395fb
--- /dev/null
+++ b/frontend/src/Album/Search/AlbumInteractiveSearchModalConnector.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
+import AlbumInteractiveSearchModal from './AlbumInteractiveSearchModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ dispatch(cancelFetchReleases());
+ dispatch(clearReleases());
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(AlbumInteractiveSearchModal);
diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js
new file mode 100644
index 000000000..ff8cbe384
--- /dev/null
+++ b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { scrollDirections } from 'Helpers/Props';
+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 AlbumInteractiveSearchModalContent(props) {
+ const {
+ albumId,
+ albumTitle,
+ onModalClose
+ } = props;
+
+ return (
+
+
+ Interactive Search {albumId != null && `- ${albumTitle}`}
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+AlbumInteractiveSearchModalContent.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ albumTitle: PropTypes.string.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AlbumInteractiveSearchModalContent;
diff --git a/frontend/src/Album/SeasonEpisodeNumber.js b/frontend/src/Album/SeasonEpisodeNumber.js
new file mode 100644
index 000000000..7242ebfcc
--- /dev/null
+++ b/frontend/src/Album/SeasonEpisodeNumber.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import EpisodeNumber from './EpisodeNumber';
+
+function SeasonEpisodeNumber(props) {
+ const {
+ airDate,
+ artistType,
+ ...otherProps
+ } = props;
+
+ if (artistType === 'daily' && airDate) {
+ return (
+ {airDate}
+ );
+ }
+
+ return (
+
+ );
+}
+
+SeasonEpisodeNumber.propTypes = {
+ airDate: PropTypes.string,
+ artistType: PropTypes.string
+};
+
+export default SeasonEpisodeNumber;
diff --git a/frontend/src/Album/TrackQuality.js b/frontend/src/Album/TrackQuality.js
new file mode 100644
index 000000000..866e7d11f
--- /dev/null
+++ b/frontend/src/Album/TrackQuality.js
@@ -0,0 +1,61 @@
+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) {
+ if (!title) {
+ return;
+ }
+
+ 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 TrackQuality(props) {
+ const {
+ className,
+ title,
+ quality,
+ size,
+ isCutoffNotMet
+ } = props;
+
+ return (
+
+ {quality.quality.name}
+
+ );
+}
+
+TrackQuality.propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string,
+ quality: PropTypes.object.isRequired,
+ size: PropTypes.number,
+ isCutoffNotMet: PropTypes.bool
+};
+
+TrackQuality.defaultProps = {
+ title: ''
+};
+
+export default TrackQuality;
diff --git a/frontend/src/Album/albumEntities.js b/frontend/src/Album/albumEntities.js
new file mode 100644
index 000000000..4f5a26a61
--- /dev/null
+++ b/frontend/src/Album/albumEntities.js
@@ -0,0 +1,13 @@
+export const CALENDAR = 'calendar';
+export const ALBUMS = 'albums';
+export const INTERACTIVE_IMPORT = 'interactiveImport.albums';
+export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
+export const WANTED_MISSING = 'wanted.missing';
+
+export default {
+ CALENDAR,
+ ALBUMS,
+ INTERACTIVE_IMPORT,
+ WANTED_CUTOFF_UNMET,
+ WANTED_MISSING
+};
diff --git a/frontend/src/AlbumStudio/AlbumStudio.js b/frontend/src/AlbumStudio/AlbumStudio.js
new file mode 100644
index 000000000..39222a9e2
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudio.js
@@ -0,0 +1,218 @@
+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, 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 NoArtist from 'Artist/NoArtist';
+import AlbumStudioFilterModalConnector from './AlbumStudioFilterModalConnector';
+import AlbumStudioRowConnector from './AlbumStudioRowConnector';
+import AlbumStudioFooter from './AlbumStudioFooter';
+
+const columns = [
+ {
+ name: 'status',
+ isVisible: true
+ },
+ {
+ name: 'sortName',
+ label: 'Name',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'monitored',
+ isVisible: true
+ },
+ {
+ name: 'albumCount',
+ label: 'Albums',
+ isSortable: true,
+ isVisible: true
+ }
+];
+
+class AlbumStudio extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {}
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onUpdateSelectedPress = (changes) => {
+ this.props.onUpdateSelectedPress({
+ artistIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ totalItems,
+ items,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ {getErrorMessage(error, 'Failed to load artist from API')}
+ }
+
+ {
+ !error && isPopulated && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AlbumStudio.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalItems: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onUpdateSelectedPress: PropTypes.func.isRequired
+};
+
+export default AlbumStudio;
diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.css b/frontend/src/AlbumStudio/AlbumStudioAlbum.css
new file mode 100644
index 000000000..f3c9f6102
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioAlbum.css
@@ -0,0 +1,37 @@
+.album {
+ display: flex;
+ align-items: stretch;
+ overflow: hidden;
+ margin: 2px 4px;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: #eee;
+ cursor: default;
+}
+
+.info {
+ padding: 0 4px;
+}
+
+.albumType {
+ padding: 0 4px;
+ border-width: 0 1px;
+ border-style: solid;
+ border-color: $borderColor;
+ background-color: $white;
+ color: $defaultColor;
+}
+
+.tracks {
+ padding: 0 4px;
+ background-color: $white;
+ color: $defaultColor;
+}
+
+.allTracks {
+ background-color: #e0ffe0;
+}
+
+.missingWanted {
+ background-color: #ffe0e0;
+}
diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.js b/frontend/src/AlbumStudio/AlbumStudioAlbum.js
new file mode 100644
index 000000000..8bec82840
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioAlbum.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import styles from './AlbumStudioAlbum.css';
+
+class AlbumStudioAlbum extends Component {
+
+ //
+ // Listeners
+
+ onAlbumMonitoredPress = () => {
+ const {
+ id,
+ monitored
+ } = this.props;
+
+ this.props.onAlbumMonitoredPress(id, !monitored);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ disambiguation,
+ albumType,
+ monitored,
+ statistics,
+ isSaving
+ } = this.props;
+
+ const {
+ trackFileCount,
+ totalTrackCount,
+ percentOfTracks
+ } = statistics;
+
+ return (
+
+
+
+
+
+ {
+ disambiguation ? `${title} (${disambiguation})` : `${title}`
+ }
+
+
+
+
+
+ {
+ `${albumType}`
+ }
+
+
+
+
+ {
+ totalTrackCount === 0 ? '0/0' : `${trackFileCount}/${totalTrackCount}`
+ }
+
+
+ );
+ }
+}
+
+AlbumStudioAlbum.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ albumType: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ statistics: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onAlbumMonitoredPress: PropTypes.func.isRequired
+};
+
+AlbumStudioAlbum.defaultProps = {
+ isSaving: false,
+ statistics: {
+ trackFileCount: 0,
+ totalTrackCount: 0,
+ percentOfTracks: 0
+ }
+};
+
+export default AlbumStudioAlbum;
diff --git a/frontend/src/AlbumStudio/AlbumStudioConnector.js b/frontend/src/AlbumStudio/AlbumStudioConnector.js
new file mode 100644
index 000000000..12e863c24
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioConnector.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 createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setAlbumStudioSort, setAlbumStudioFilter, saveAlbumStudio } from 'Store/Actions/albumStudioActions';
+import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
+import AlbumStudio from './AlbumStudio';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('artist', 'albumStudio'),
+ (artist) => {
+ return {
+ ...artist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchAlbums,
+ clearAlbums,
+ setAlbumStudioSort,
+ setAlbumStudioFilter,
+ saveAlbumStudio
+};
+
+class AlbumStudioConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.populate();
+ }
+
+ componentWillUnmount() {
+ this.unpopulate();
+ }
+
+ //
+ // Control
+
+ populate = () => {
+ this.props.fetchAlbums();
+ }
+
+ unpopulate = () => {
+ this.props.clearAlbums();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.setAlbumStudioSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setAlbumStudioFilter({ selectedFilterKey });
+ }
+
+ onUpdateSelectedPress = (payload) => {
+ this.props.saveAlbumStudio(payload);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AlbumStudioConnector.propTypes = {
+ setAlbumStudioSort: PropTypes.func.isRequired,
+ setAlbumStudioFilter: PropTypes.func.isRequired,
+ fetchAlbums: PropTypes.func.isRequired,
+ clearAlbums: PropTypes.func.isRequired,
+ saveAlbumStudio: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioConnector);
diff --git a/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js b/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js
new file mode 100644
index 000000000..655601cca
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setAlbumStudioFilter } from 'Store/Actions/albumStudioActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artist.items,
+ (state) => state.albumStudio.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'albumStudio'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setAlbumStudioFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.css b/frontend/src/AlbumStudio/AlbumStudioFooter.css
new file mode 100644
index 000000000..11ea5496a
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioFooter.css
@@ -0,0 +1,14 @@
+.inputContainer {
+ margin-right: 20px;
+}
+
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.updateSelectedButton {
+ composes: button from '~Components/Link/SpinnerButton.css';
+
+ height: 35px;
+}
diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.js b/frontend/src/AlbumStudio/AlbumStudioFooter.js
new file mode 100644
index 000000000..d5eb300cd
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioFooter.js
@@ -0,0 +1,145 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import MonitorAlbumsSelectInput from 'Components/Form/MonitorAlbumsSelectInput';
+import SelectInput from 'Components/Form/SelectInput';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import styles from './AlbumStudioFooter.css';
+
+const NO_CHANGE = 'noChange';
+
+class AlbumStudioFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ monitored: NO_CHANGE,
+ monitor: NO_CHANGE
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = prevProps;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.setState({
+ monitored: NO_CHANGE,
+ monitor: NO_CHANGE
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ }
+
+ onUpdateSelectedPress = () => {
+ const {
+ monitor,
+ monitored
+ } = this.state;
+
+ const changes = {};
+
+ if (monitored !== NO_CHANGE) {
+ changes.monitored = monitored === 'monitored';
+ }
+
+ if (monitor !== NO_CHANGE) {
+ changes.monitor = monitor;
+ }
+
+ this.props.onUpdateSelectedPress(changes);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedCount,
+ isSaving
+ } = this.props;
+
+ const {
+ monitored,
+ monitor
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored' },
+ { key: 'unmonitored', value: 'Unmonitored' }
+ ];
+
+ const noChanges = monitored === NO_CHANGE && monitor === NO_CHANGE;
+
+ return (
+
+
+
+ Monitor Artist
+
+
+
+
+
+
+
+ Monitor Albums
+
+
+
+
+
+
+
+ {selectedCount} Artist(s) Selected
+
+
+
+ Update Selected
+
+
+
+ );
+ }
+}
+
+AlbumStudioFooter.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ onUpdateSelectedPress: PropTypes.func.isRequired
+};
+
+export default AlbumStudioFooter;
diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.css b/frontend/src/AlbumStudio/AlbumStudioRow.css
new file mode 100644
index 000000000..7b9d1f52b
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioRow.css
@@ -0,0 +1,20 @@
+.status,
+.monitored {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.title {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 1px;
+ white-space: nowrap;
+}
+
+.albums {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.js b/frontend/src/AlbumStudio/AlbumStudioRow.js
new file mode 100644
index 000000000..f6a146999
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioRow.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import AlbumStudioAlbum from './AlbumStudioAlbum';
+import styles from './AlbumStudioRow.css';
+
+class AlbumStudioRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistId,
+ status,
+ foreignArtistId,
+ artistName,
+ monitored,
+ albums,
+ isSaving,
+ isSelected,
+ onSelectedChange,
+ onArtistMonitoredPress,
+ onAlbumMonitoredPress
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ albums.map((album) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+ }
+}
+
+AlbumStudioRow.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ status: PropTypes.string.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ albums: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onArtistMonitoredPress: PropTypes.func.isRequired,
+ onAlbumMonitoredPress: PropTypes.func.isRequired
+};
+
+AlbumStudioRow.defaultProps = {
+ isSaving: false
+};
+
+export default AlbumStudioRow;
diff --git a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js
new file mode 100644
index 000000000..901f9407e
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js
@@ -0,0 +1,83 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { toggleArtistMonitored } from 'Store/Actions/artistActions';
+import { toggleAlbumsMonitored } from 'Store/Actions/albumActions';
+import AlbumStudioRow from './AlbumStudioRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.albums,
+ createArtistSelector(),
+ (albums, artist) => {
+ const albumsInArtist = _.filter(albums.items, { artistId: artist.id });
+ const sortedAlbums = _.orderBy(albumsInArtist, 'releaseDate', 'desc');
+
+ return {
+ ...artist,
+ artistId: artist.id,
+ artistName: artist.artistName,
+ monitored: artist.monitored,
+ status: artist.status,
+ isSaving: artist.isSaving,
+ albums: sortedAlbums
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ toggleArtistMonitored,
+ toggleAlbumsMonitored
+};
+
+class AlbumStudioRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onArtistMonitoredPress = () => {
+ const {
+ artistId,
+ monitored
+ } = this.props;
+
+ this.props.toggleArtistMonitored({
+ artistId,
+ monitored: !monitored
+ });
+ }
+
+ onAlbumMonitoredPress = (albumId, monitored) => {
+ const albumIds = [albumId];
+ this.props.toggleAlbumsMonitored({
+ albumIds,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AlbumStudioRowConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ toggleArtistMonitored: PropTypes.func.isRequired,
+ toggleAlbumsMonitored: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioRowConnector);
diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js
new file mode 100644
index 000000000..ecd3ea533
--- /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 'connected-react-router';
+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..ed55547e0
--- /dev/null
+++ b/frontend/src/App/AppRoutes.js
@@ -0,0 +1,267 @@
+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 ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
+import AddNewArtistConnector from 'AddArtist/AddNewArtist/AddNewArtistConnector';
+import ImportArtist from 'AddArtist/ImportArtist/ImportArtist';
+import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
+import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
+import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
+import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector';
+import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector';
+import CalendarPageConnector from 'Calendar/CalendarPageConnector';
+import HistoryConnector from 'Activity/History/HistoryConnector';
+import QueueConnector from 'Activity/Queue/QueueConnector';
+import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector';
+import MissingConnector from 'Wanted/Missing/MissingConnector';
+import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
+import Settings from 'Settings/Settings';
+import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
+import Profiles from 'Settings/Profiles/Profiles';
+import Quality from 'Settings/Quality/Quality';
+import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
+import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
+import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
+import NotificationSettings from 'Settings/Notifications/NotificationSettings';
+import MetadataSettings from 'Settings/Metadata/MetadataSettings';
+import TagSettings from 'Settings/Tags/TagSettings';
+import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
+import UISettingsConnector from 'Settings/UI/UISettingsConnector';
+import Status from 'System/Status/Status';
+import Tasks from 'System/Tasks/Tasks';
+import BackupsConnector from 'System/Backup/BackupsConnector';
+import UpdatesConnector from 'System/Updates/UpdatesConnector';
+import LogsTableConnector from 'System/Events/LogsTableConnector';
+import Logs from 'System/Logs/Logs';
+
+function AppRoutes(props) {
+ const {
+ app
+ } = props;
+
+ return (
+
+ {/*
+ Artist
+ */}
+
+
+
+ {
+ window.Lidarr.urlBase &&
+ {
+ return (
+
+ );
+ }}
+ />
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Calendar
+ */}
+
+
+
+ {/*
+ Activity
+ */}
+
+
+
+
+
+
+
+ {/*
+ Wanted
+ */}
+
+
+
+
+
+ {/*
+ Settings
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ System
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not Found
+ */}
+
+
+
+
+ );
+}
+
+AppRoutes.propTypes = {
+ app: PropTypes.func.isRequired
+};
+
+export default AppRoutes;
diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js
new file mode 100644
index 000000000..abc7f8832
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModal.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
+
+function AppUpdatedModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AppUpdatedModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js
new file mode 100644
index 000000000..a21afbc5a
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import AppUpdatedModal from './AppUpdatedModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(AppUpdatedModal);
diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css
new file mode 100644
index 000000000..37b89c9be
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.css
@@ -0,0 +1,15 @@
+.version {
+ margin: 0 3px;
+ font-weight: bold;
+}
+
+.maintenance {
+ margin-top: 20px;
+}
+
+.changes {
+ margin-top: 20px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+ font-size: 18px;
+}
diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js
new file mode 100644
index 000000000..9597d538f
--- /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 (
+
+
+ Lidarr Updated
+
+
+
+
+ Version {version} of Lidarr has been installed, in order to get the latest changes you'll need to reload Lidarr.
+
+
+ {
+ 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..7cf649b65
--- /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.Lidarr.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..9178d2ab8
--- /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
+
+
+
+
+ Lidarr has lost it's connection to the backend and will need to be reloaded to restore functionality.
+
+
+
+ Lidarr will try to connect automatically, or you can click reload below.
+
+
+
+
+ Reload
+
+
+
+
+ );
+}
+
+ConnectionLostModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js
new file mode 100644
index 000000000..8ab8e3cd0
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import ConnectionLostModal from './ConnectionLostModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);
diff --git a/frontend/src/Artist/ArtistBanner.js b/frontend/src/Artist/ArtistBanner.js
new file mode 100644
index 000000000..b409667b1
--- /dev/null
+++ b/frontend/src/Artist/ArtistBanner.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ArtistImage from './ArtistImage';
+
+const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAC5AgMAAADG9/24AAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QkRBgAc5PUQ8QAAB7tJREFUeNrtnb1rHEsMwMdjArtrUrpf3uMg2cOl+5QpXWRvjQkXlyHVK8MVYXFl3F+/GAzrNfdSuXkQnH9ie/Oqw32aFM6bkTQfd85rHrwi0qjJJql+pxlJo9FISv0XqX8mKkmSJEmSJEmSJEmSJEmSJEmS5NeVYrh7HDqJ5DeY23kQB64/u7zW91amzgXqvRqjfGYvarnfxqncuaQlf72Zxv4gSOluudOfjRy1Hzh1L+nPj+KU3jh0MWr3Sp87dDFq98An/msmg3zPW/aFR6/vRaBPglML6Mci0H0g92MYfrjvRgJ5TrDvhuFyGIZv9NdTOev93dDry1Z59mM56/2hU8WZcefFjZgVT/Z90SlEV9VKio3HeKYZLLSGII6WPP+oBv3ZwkL3iE4JG/ZRjUYb16mAripUO/c4Hl3bd/juCF19FuHeJn6nK90VBh0s3SjBvcFW/wTrvfBa118EbHY8qmMOtmgdupoKOLTvAmOH1k059LKAX+Qra/TncExH4hcBXV3Zf/+Dv5Vb43cfod/wt3PLsN5VF6HDiudt56IbB23Ql5/oZ8A7CfZWbgF61sap62XdlOZTZ2rF3c7ltNW1+f7LuDez/uc6uDfO8dwkbPXcKF8vPS9sds527pC2utH0uCb0Jmz2t8wNvN3q2jj4ntDJna94m3g4sb7HH8Gue0RH4Je8z61g4GGr79Wz1qHjbi94m/jcH1IOccsj+lt/sOFr4m0EPz+3XyNueURvSmfn+Ebx1rctMvOxU8foqOwVa+9mfdvHDH+DdYQOxAesvZslvc/wo4vQZy6eY+vdrCVrut/AyTW9CWvO3E1ra4L6i5Fxoka7MDan45vTOmx2MPGc0QH52Tb6kTPxXNGtW5/Tnl+oGP2N/dstY8eeO2M+bqM3zvVxRT+gS8Vdl5/z6CaCzfx/c41o3pP2+030UzrAHDNGv8d4FvMVAf1I4c07V/QlnNsyG9TN23ID/ZjObnO+6CZmydS+y8oG9Dfk2Gd80WcW3Rv44e5bZOLtD8EUHbRq0V0F/DAMn8fQ2UUv2UayEMxplaFvM0G7QR/ufqBcmH/gG85Z9BM8rMO5bdiUDu4ceaLvUjqWfJveQm8hpvnKFv1jOLxUTtkoBWf0nIK5Cd6wO207dAznTlmjH+K630KvuKPbOHYffJsOexykx0iWJ7pNRf+ttEXvW1U49F7ZbJ26RHSe6ehn5NRGMPBVQAfP12EQf8QZPTMxXac30M1RxtaWXLBFn3j0eRsHNIBuCycLtqfWCQZrBcZ0W+gVhXts0RtEXyit4zDOoE/N/5yNzNF3oVB0A93I6ise7bijr1XwbYiurySg7xjfpp+g375mjj5D9HYD3fr6YvkKcxU80Q8duvVt2+gjob/ljX7yFH1a80dvDfpia8GXqp3Wr1XJXevljvFt2QZ6a6tJ2Gvd2Pa8Xuu2iNB7o++r+lUJlQas93oOl04be73Ut7y1Ts4tn/0EfaxZOzcKaXIsqNiI4QtE5x7N7Z08RZ9CKpb38cWs9V26b4sDWURnr3W9foq+gpM8a3S4V+rhKUCcqrBXTufMUxU2QWUTkFtZGls3pjgnqJ4FdB1lZAd8/qMEZGRtAlJvJqONb2syzuj2CuK+1YU5reg2CzGNyqq6fpOpku8VRI7lchX+7SIy8NdQSaKn3K8bsWJOlZGBX2Ed0bUIdBXftJpzG1h2vjetWFqgHXrhczTWtx2h8llXVTytI4FHnaes0bGMqNhC1+jUXmFMx7iCaq6qy4HqxR7dFfMUtc34LQCVDPppcM13Kie5RmTGJYNUKBraDL57xEKalS0UzTgXiiqn1X3fRxR0bBf6TLEvD4aKkslTdO5F4fQUIHseo5ugfrShewbWjvsriHxT65WAByAHVCT7u0fvoKDC+rZClZyf/eSAngUTj1pfCXjshU/8smDiPTr7J374sDPDfI1Hd4cX1g874TmvRc+30U8V9+e88Ig7U6V26Ofk21rzg1ScH3Hj033bTXIZab2iGG6PdWOaQzLx7cShQ0FFfdxqlfFu2DAhxw4vf5zWV3hYZ96m47l7u0+DMAgdu1Dxbs4St+Rx6MbAwzIveLfk8Y2Y9J5HP1tij3DmjZii9lujQy9GXO/M22/5pmvUVdWiV3RkYd50zbfaI7Vb9GmDD0C4t9qLGiy+JPTVKT4A4d5gMY866N4i+p9KuUM767aavpkqLnmDDl10S8W/mSq20O3I3n8x6AXufP4tdKlxcpkZ4HN43FZ0mT3GCmicTO2ysW2uVXmFWp/yb5cdN0kHrb/ADwFN0uPW+ICOt+0SWuPjkW2tPTr+CjcSphjiGIzWodPQGwljMGj4Se/Q0bfJGH4Sj7zx6DJG3tCgo4HQoYiukDHoKB5vZdCtb9MrIUM7DyK169Zu+mEUMtQsjLJrtT7vWl2IGWXnBxjaFwFnraQBhmFs5dDqi66SNLYyGlY6XMgaVip4RG00mHh2LWwwcRhHPVsJG0cdhpDProQNIQ+j52e30kbPa19B5T64H9Wfqn0mTelB7Y04pWMFfCQf5JDTjYOTuSClu5QUSa9EyU0gf5BF7iaP2zxdq6TJjUydgxTD3aPvm5wkSZIkSZIkSZIkSZIkSZIk+Z9kv/534U2+Uyf0XwP9H83PZBlAqkdjAAAAAElFTkSuQmCC';
+
+function ArtistBanner(props) {
+ return (
+
+ );
+}
+
+ArtistBanner.propTypes = {
+ size: PropTypes.number.isRequired
+};
+
+ArtistBanner.defaultProps = {
+ size: 70
+};
+
+export default ArtistBanner;
diff --git a/frontend/src/Artist/ArtistImage.js b/frontend/src/Artist/ArtistImage.js
new file mode 100644
index 000000000..6ae479a18
--- /dev/null
+++ b/frontend/src/Artist/ArtistImage.js
@@ -0,0 +1,200 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+function findImage(images, coverType) {
+ return images.find((image) => image.coverType === coverType);
+}
+
+function getUrl(image, coverType, size) {
+ if (image) {
+ // Remove protocol
+ let url = image.url.replace(/^https?:/, '');
+
+ url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
+
+ return url;
+ }
+}
+
+class ArtistImage extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.ceil(window.devicePixelRatio);
+
+ const {
+ images,
+ coverType,
+ size
+ } = props;
+
+ const image = findImage(images, coverType);
+
+ this.state = {
+ pixelRatio,
+ image,
+ url: getUrl(image, coverType, pixelRatio * size),
+ isLoaded: false,
+ hasError: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.state.url && this.props.onError) {
+ this.props.onError();
+ }
+ }
+
+ componentDidUpdate() {
+ const {
+ images,
+ coverType,
+ placeholder,
+ size,
+ onError
+ } = this.props;
+
+ const {
+ image,
+ pixelRatio
+ } = this.state;
+
+ const nextImage = findImage(images, coverType);
+
+ if (nextImage && (!image || nextImage.url !== image.url)) {
+ this.setState({
+ image: nextImage,
+ url: getUrl(nextImage, coverType, pixelRatio * size),
+ hasError: false
+ // Don't reset isLoaded, as we want to immediately try to
+ // show the new image, whether an image was shown previously
+ // or the placeholder was shown.
+ });
+ } else if (!nextImage && image) {
+ this.setState({
+ image: nextImage,
+ url: placeholder,
+ hasError: false
+ });
+
+ if (onError) {
+ onError();
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({
+ hasError: true
+ });
+
+ if (this.props.onError) {
+ this.props.onError();
+ }
+ }
+
+ onLoad = () => {
+ this.setState({
+ isLoaded: true,
+ hasError: false
+ });
+
+ if (this.props.onLoad) {
+ this.props.onLoad();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ placeholder,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ url,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !url) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistImage.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ coverType: PropTypes.string.isRequired,
+ placeholder: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ lazy: PropTypes.bool.isRequired,
+ overflow: PropTypes.bool.isRequired,
+ onError: PropTypes.func,
+ onLoad: PropTypes.func
+};
+
+ArtistImage.defaultProps = {
+ size: 250,
+ lazy: true,
+ overflow: false
+};
+
+export default ArtistImage;
diff --git a/frontend/src/Artist/ArtistLogo.js b/frontend/src/Artist/ArtistLogo.js
new file mode 100644
index 000000000..05e665186
--- /dev/null
+++ b/frontend/src/Artist/ArtistLogo.js
@@ -0,0 +1,160 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+const logoPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII=';
+
+function findLogo(images) {
+ return _.find(images, { coverType: 'logo' });
+}
+
+function getLogoUrl(logo, size) {
+ if (logo) {
+ // Remove protocol
+ let url = logo.url.replace(/^https?:/, '');
+ url = url.replace('logo.jpg', `logo-${size}.jpg`);
+
+ return url;
+ }
+}
+
+class ArtistLogo extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.floor(window.devicePixelRatio);
+
+ const {
+ images,
+ size
+ } = props;
+
+ const logo = findLogo(images);
+
+ this.state = {
+ pixelRatio,
+ logo,
+ logoUrl: getLogoUrl(logo, pixelRatio * size),
+ hasError: false,
+ isLoaded: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ images,
+ size
+ } = this.props;
+
+ const {
+ pixelRatio
+ } = this.state;
+
+ const logo = findLogo(images);
+
+ if (logo && logo.url !== this.state.logo.url) {
+ this.setState({
+ logo,
+ logoUrl: getLogoUrl(logo, pixelRatio * size),
+ hasError: false,
+ isLoaded: false
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({ hasError: true });
+ }
+
+ onLoad = () => {
+ this.setState({ isLoaded: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ logoUrl,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !logoUrl) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistLogo.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
+};
+
+ArtistLogo.defaultProps = {
+ size: 250,
+ lazy: true,
+ overflow: false
+};
+
+export default ArtistLogo;
diff --git a/frontend/src/Artist/ArtistNameLink.js b/frontend/src/Artist/ArtistNameLink.js
new file mode 100644
index 000000000..fab1cb974
--- /dev/null
+++ b/frontend/src/Artist/ArtistNameLink.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+
+function ArtistNameLink({ foreignArtistId, artistName }) {
+ const link = `/artist/${foreignArtistId}`;
+
+ return (
+
+ {artistName}
+
+ );
+}
+
+ArtistNameLink.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired
+};
+
+export default ArtistNameLink;
diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js
new file mode 100644
index 000000000..4eebd9ca4
--- /dev/null
+++ b/frontend/src/Artist/ArtistPoster.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ArtistImage from './ArtistImage';
+
+const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII=';
+
+function ArtistPoster(props) {
+ return (
+
+ );
+}
+
+ArtistPoster.propTypes = {
+ size: PropTypes.number.isRequired
+};
+
+ArtistPoster.defaultProps = {
+ size: 250
+};
+
+export default ArtistPoster;
diff --git a/frontend/src/Artist/Delete/DeleteArtistModal.js b/frontend/src/Artist/Delete/DeleteArtistModal.js
new file mode 100644
index 000000000..5b6490c66
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
+
+function DeleteArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModal;
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Delete/DeleteArtistModalContent.css
new file mode 100644
index 000000000..dbfef0871
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.css
@@ -0,0 +1,12 @@
+.pathContainer {
+ margin-bottom: 20px;
+}
+
+.pathIcon {
+ margin-right: 8px;
+}
+
+.deleteFilesMessage {
+ margin-top: 20px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js
new file mode 100644
index 000000000..a242a1e20
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './DeleteArtistModalContent.css';
+
+class DeleteArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false,
+ addImportListExclusion: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onAddImportListExclusionChange = ({ value }) => {
+ this.setState({ addImportListExclusion: value });
+ }
+
+ onDeleteArtistConfirmed = () => {
+ const deleteFiles = this.state.deleteFiles;
+ const addImportListExclusion = this.state.addImportListExclusion;
+
+ this.setState({ deleteFiles: false });
+ this.setState({ addImportListExclusion: false });
+ this.props.onDeletePress(deleteFiles, addImportListExclusion);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ path,
+ statistics,
+ onModalClose
+ } = this.props;
+
+ const {
+ trackFileCount,
+ sizeOnDisk
+ } = statistics;
+
+ const deleteFiles = this.state.deleteFiles;
+ const addImportListExclusion = this.state.addImportListExclusion;
+
+ let deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
+ let deleteFilesHelpText = 'Delete the track files and artist folder';
+
+ if (trackFileCount === 0) {
+ deleteFilesLabel = 'Delete Artist Folder';
+ deleteFilesHelpText = 'Delete the artist folder and its contents';
+ }
+
+ return (
+
+
+ Delete - {artistName}
+
+
+
+
+
+
+ {path}
+
+
+
+ {deleteFilesLabel}
+
+
+
+
+
+ Add List Exclusion
+
+
+
+
+ {
+ deleteFiles &&
+
+
The artist folder {path} and all of its content will be deleted.
+
+ {
+ !!trackFileCount &&
+
{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}
+ }
+
+ }
+
+
+
+
+
+ Close
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteArtistModalContent.propTypes = {
+ artistName: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+DeleteArtistModalContent.defaultProps = {
+ statistics: {
+ trackFileCount: 0
+ }
+};
+
+export default DeleteArtistModalContent;
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js
new file mode 100644
index 000000000..e0ea034ab
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { deleteArtist } from 'Store/Actions/artistActions';
+import DeleteArtistModalContent from './DeleteArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return artist;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteArtist
+};
+
+class DeleteArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = (deleteFiles, addImportListExclusion) => {
+ this.props.deleteArtist({
+ id: this.props.artistId,
+ deleteFiles,
+ addImportListExclusion
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DeleteArtistModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ deleteArtist: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector);
diff --git a/frontend/src/Artist/Details/AlbumRow.css b/frontend/src/Artist/Details/AlbumRow.css
new file mode 100644
index 000000000..e29f491d7
--- /dev/null
+++ b/frontend/src/Artist/Details/AlbumRow.css
@@ -0,0 +1,17 @@
+.title {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
+
+.monitored {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 42px;
+}
+
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Artist/Details/AlbumRow.js b/frontend/src/Artist/Details/AlbumRow.js
new file mode 100644
index 000000000..e2d6cf65e
--- /dev/null
+++ b/frontend/src/Artist/Details/AlbumRow.js
@@ -0,0 +1,263 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import { kinds, sizes } from 'Helpers/Props';
+import TableRow from 'Components/Table/TableRow';
+import Label from 'Components/Label';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector';
+import AlbumTitleLink from 'Album/AlbumTitleLink';
+import StarRating from 'Components/StarRating';
+import styles from './AlbumRow.css';
+
+function getTrackCountKind(monitored, trackFileCount, trackCount) {
+ if (trackFileCount === trackCount && trackCount > 0) {
+ return kinds.SUCCESS;
+ }
+
+ if (!monitored) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+class AlbumRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false,
+ isEditAlbumModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onManualSearchPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ onEditAlbumPress = () => {
+ this.setState({ isEditAlbumModalOpen: true });
+ }
+
+ onEditAlbumModalClose = () => {
+ this.setState({ isEditAlbumModalOpen: false });
+ }
+
+ onMonitorAlbumPress = (monitored, options) => {
+ this.props.onMonitorAlbumPress(this.props.id, monitored, options);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ artistId,
+ monitored,
+ statistics,
+ duration,
+ releaseDate,
+ mediumCount,
+ secondaryTypes,
+ title,
+ ratings,
+ disambiguation,
+ isSaving,
+ artistMonitored,
+ foreignAlbumId,
+ columns
+ } = this.props;
+
+ const {
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ } = statistics;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'monitored') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'title') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'mediumCount') {
+ return (
+
+ {
+ mediumCount
+ }
+
+ );
+ }
+
+ if (name === 'secondaryTypes') {
+ return (
+
+ {
+ secondaryTypes
+ }
+
+ );
+ }
+
+ if (name === 'trackCount') {
+ return (
+
+ {
+ statistics.totalTrackCount
+ }
+
+ );
+ }
+
+ if (name === 'duration') {
+ return (
+
+ {
+ formatTimeSpan(duration)
+ }
+
+ );
+ }
+
+ if (name === 'rating') {
+ return (
+
+ {
+
+ }
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+ {
+ {trackFileCount} / {trackCount}
+ }
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+ return null;
+ })
+ }
+
+ );
+ }
+}
+
+AlbumRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ mediumCount: PropTypes.number.isRequired,
+ duration: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ ratings: PropTypes.object.isRequired,
+ disambiguation: PropTypes.string,
+ secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ isSaving: PropTypes.bool,
+ unverifiedSceneNumbering: PropTypes.bool,
+ artistMonitored: PropTypes.bool.isRequired,
+ statistics: PropTypes.object.isRequired,
+ mediaInfo: PropTypes.object,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMonitorAlbumPress: PropTypes.func.isRequired
+};
+
+AlbumRow.defaultProps = {
+ statistics: {
+ trackCount: 0,
+ trackFileCount: 0
+ }
+};
+
+export default AlbumRow;
diff --git a/frontend/src/Artist/Details/AlbumRowConnector.js b/frontend/src/Artist/Details/AlbumRowConnector.js
new file mode 100644
index 000000000..6e92fb1d4
--- /dev/null
+++ b/frontend/src/Artist/Details/AlbumRowConnector.js
@@ -0,0 +1,22 @@
+/* eslint max-params: 0 */
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import AlbumRow from './AlbumRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createTrackFileSelector(),
+ (artist = {}, trackFile) => {
+ return {
+ foreignArtistId: artist.foreignArtistId,
+ artistMonitored: artist.monitored,
+ trackFilePath: trackFile ? trackFile.path : null,
+ trackFileRelativePath: trackFile ? trackFile.relativePath : null
+ };
+ }
+ );
+}
+export default connect(createMapStateToProps)(AlbumRow);
diff --git a/frontend/src/Artist/Details/ArtistAlternateTitles.css b/frontend/src/Artist/Details/ArtistAlternateTitles.css
new file mode 100644
index 000000000..1af1ae68b
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistAlternateTitles.css
@@ -0,0 +1,3 @@
+.alternateTitle {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Artist/Details/ArtistAlternateTitles.js b/frontend/src/Artist/Details/ArtistAlternateTitles.js
new file mode 100644
index 000000000..e1fde52e6
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistAlternateTitles.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './ArtistAlternateTitles.css';
+
+function ArtistAlternateTitles({ alternateTitles }) {
+ return (
+
+ {
+ alternateTitles.map((alternateTitle) => {
+ return (
+
+ {alternateTitle}
+
+ );
+ })
+ }
+
+ );
+}
+
+ArtistAlternateTitles.propTypes = {
+ alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default ArtistAlternateTitles;
diff --git a/frontend/src/Artist/Details/ArtistDetails.css b/frontend/src/Artist/Details/ArtistDetails.css
new file mode 100644
index 000000000..fb3803a85
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetails.css
@@ -0,0 +1,167 @@
+.innerContentBody {
+ padding: 0;
+}
+
+.header {
+ position: relative;
+ width: 100%;
+ height: 310px;
+}
+
+.errorMessage {
+ margin-top: 20px;
+ text-align: center;
+ font-size: 20px;
+}
+
+.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: 250px;
+}
+
+.info {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.metadataMessage {
+ color: $helpTextColor;
+ text-align: center;
+ font-weight: 300;
+ font-size: 20px;
+}
+
+.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;
+}
+
+.artistNavigationButtons {
+ white-space: nowrap;
+}
+
+.artistNavigationButton {
+ 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,
+.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/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js
new file mode 100644
index 000000000..699eb3f21
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetails.js
@@ -0,0 +1,714 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
+import fonts from 'Styles/Variables/fonts';
+import HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+import 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 TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
+import ArtistAlternateTitles from './ArtistAlternateTitles';
+import ArtistDetailsSeasonConnector from './ArtistDetailsSeasonConnector';
+import ArtistTagsConnector from './ArtistTagsConnector';
+import ArtistDetailsLinks from './ArtistDetailsLinks';
+import styles from './ArtistDetails.css';
+import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
+import ArtistInteractiveSearchModalConnector from 'Artist/Search/ArtistInteractiveSearchModalConnector';
+import Link from 'Components/Link/Link';
+
+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 ArtistDetails extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isRetagModalOpen: false,
+ isManageTracksOpen: false,
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false,
+ isArtistHistoryModalOpen: false,
+ isInteractiveImportModalOpen: false,
+ isInteractiveSearchModalOpen: false,
+ allExpanded: false,
+ allCollapsed: false,
+ expandedState: {}
+ };
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onRetagPress = () => {
+ this.setState({ isRetagModalOpen: true });
+ }
+
+ onRetagModalClose = () => {
+ this.setState({ isRetagModalOpen: false });
+ }
+
+ onManageTracksPress = () => {
+ this.setState({ isManageTracksOpen: true });
+ }
+
+ onManageTracksModalClose = () => {
+ this.setState({ isManageTracksOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ onInteractiveSearchPress = () => {
+ this.setState({ isInteractiveSearchModalOpen: true });
+ }
+
+ onInteractiveSearchModalClose = () => {
+ this.setState({ isInteractiveSearchModalOpen: false });
+ }
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onArtistHistoryPress = () => {
+ this.setState({ isArtistHistoryModalOpen: true });
+ }
+
+ onArtistHistoryModalClose = () => {
+ this.setState({ isArtistHistoryModalOpen: false });
+ }
+
+ onExpandAllPress = () => {
+ const {
+ allExpanded,
+ expandedState
+ } = this.state;
+
+ this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
+ }
+
+ onExpandPress = (albumId, isExpanded) => {
+ this.setState((state) => {
+ const convertedState = {
+ allSelected: state.allExpanded,
+ allUnselected: state.allCollapsed,
+ selectedState: state.expandedState
+ };
+
+ const newState = toggleSelected(convertedState, [], albumId, isExpanded, false);
+
+ return getExpandedState(newState);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ foreignArtistId,
+ artistName,
+ ratings,
+ path,
+ statistics,
+ qualityProfileId,
+ monitored,
+ albumTypes,
+ status,
+ overview,
+ links,
+ images,
+ artistType,
+ alternateTitles,
+ tags,
+ isSaving,
+ isRefreshing,
+ isSearching,
+ isFetching,
+ isPopulated,
+ albumsError,
+ trackFilesError,
+ hasAlbums,
+ hasMonitoredAlbums,
+ hasTrackFiles,
+ previousArtist,
+ nextArtist,
+ onMonitorTogglePress,
+ onRefreshPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ trackFileCount,
+ sizeOnDisk
+ } = statistics;
+
+ const {
+ isOrganizeModalOpen,
+ isRetagModalOpen,
+ isManageTracksOpen,
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen,
+ isArtistHistoryModalOpen,
+ isInteractiveImportModalOpen,
+ isInteractiveSearchModalOpen,
+ allExpanded,
+ allCollapsed,
+ expandedState
+ } = this.state;
+
+ const continuing = status === 'continuing';
+ const endedString = artistType === 'Person' ? 'Deceased' : 'Ended';
+
+ let trackFilesCountMessage = 'No track files';
+
+ if (trackFileCount === 1) {
+ trackFilesCountMessage = '1 track file';
+ } else if (trackFileCount > 1) {
+ trackFilesCountMessage = `${trackFileCount} track files`;
+ }
+
+ let expandIcon = icons.EXPAND_INDETERMINATE;
+
+ if (allExpanded) {
+ expandIcon = icons.COLLAPSE;
+ } else if (allCollapsed) {
+ expandIcon = icons.EXPAND;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {artistName}
+
+
+ {
+ !!alternateTitles.length &&
+
+
+ }
+ title="Alternate Titles"
+ body={
}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {path}
+
+
+
+
+
+
+
+ {
+ formatBytes(sizeOnDisk)
+ }
+
+
+
+
+
+
+
+ {
+
+ }
+
+
+
+
+
+
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+
+
+
+
+
+
+ {continuing ? 'Continuing' : endedString}
+
+
+
+
+
+
+
+ Links
+
+
+ }
+ tooltip={
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ {
+ !!tags.length &&
+
+
+
+
+ Tags
+
+
+ }
+ tooltip={ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ !isPopulated && !albumsError && !trackFilesError &&
+
+ }
+
+ {
+ !isFetching && albumsError &&
+
Loading albums failed
+ }
+
+ {
+ !isFetching && trackFilesError &&
+
Loading track files failed
+ }
+
+ {
+ isPopulated && !!albumTypes.length &&
+
+ {
+ albumTypes.slice(0).map((albumType) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+
+
+
+ Missing Albums, Singles, or Other Types? Modify or Create a New Metadata Profile!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistDetails.propTypes = {
+ id: PropTypes.number.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ ratings: PropTypes.object.isRequired,
+ path: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ artistType: PropTypes.string,
+ albumTypes: PropTypes.arrayOf(PropTypes.string),
+ status: PropTypes.string.isRequired,
+ overview: PropTypes.string.isRequired,
+ links: PropTypes.arrayOf(PropTypes.object).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,
+ albumsError: PropTypes.object,
+ trackFilesError: PropTypes.object,
+ hasAlbums: PropTypes.bool.isRequired,
+ hasMonitoredAlbums: PropTypes.bool.isRequired,
+ hasTrackFiles: PropTypes.bool.isRequired,
+ previousArtist: PropTypes.object.isRequired,
+ nextArtist: PropTypes.object.isRequired,
+ onMonitorTogglePress: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+ArtistDetails.defaultProps = {
+ statistics: {},
+ tags: [],
+ isSaving: false
+};
+
+export default ArtistDetails;
diff --git a/frontend/src/Artist/Details/ArtistDetailsConnector.js b/frontend/src/Artist/Details/ArtistDetailsConnector.js
new file mode 100644
index 000000000..2e5ba1d11
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsConnector.js
@@ -0,0 +1,285 @@
+/* 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 { findCommand, isCommandExecuting } from 'Utilities/Command';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
+import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import { toggleArtistMonitored } from 'Store/Actions/artistActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import ArtistDetails from './ArtistDetails';
+
+const selectAlbums = createSelector(
+ (state) => state.albums,
+ (albums) => {
+ const {
+ items,
+ isFetching,
+ isPopulated,
+ error
+ } = albums;
+
+ const hasAlbums = !!items.length;
+ const hasMonitoredAlbums = items.some((e) => e.monitored);
+
+ return {
+ isAlbumsFetching: isFetching,
+ isAlbumsPopulated: isPopulated,
+ albumsError: error,
+ hasAlbums,
+ hasMonitoredAlbums
+ };
+ }
+);
+
+const selectTrackFiles = createSelector(
+ (state) => state.trackFiles,
+ (trackFiles) => {
+ const {
+ items,
+ isFetching,
+ isPopulated,
+ error
+ } = trackFiles;
+
+ const hasTrackFiles = !!items.length;
+
+ return {
+ isTrackFilesFetching: isFetching,
+ isTrackFilesPopulated: isPopulated,
+ trackFilesError: error,
+ hasTrackFiles
+ };
+ }
+);
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { foreignArtistId }) => foreignArtistId,
+ selectAlbums,
+ selectTrackFiles,
+ (state) => state.settings.metadataProfiles,
+ createAllArtistSelector(),
+ createCommandsSelector(),
+ (foreignArtistId, albums, trackFiles, metadataProfiles, allArtists, commands) => {
+ const sortedArtist = _.orderBy(allArtists, 'sortName');
+ const artistIndex = _.findIndex(sortedArtist, { foreignArtistId });
+ const artist = sortedArtist[artistIndex];
+ const metadataProfile = _.find(metadataProfiles.items, { id: artist.metadataProfileId });
+ const albumTypes = _.reduce(metadataProfile.primaryAlbumTypes, (acc, primaryType) => {
+ if (primaryType.allowed) {
+ acc.push(primaryType.albumType.name);
+ }
+ return acc;
+ }, []);
+
+ if (!artist) {
+ return {};
+ }
+
+ const {
+ isAlbumsFetching,
+ isAlbumsPopulated,
+ albumsError,
+ hasAlbums,
+ hasMonitoredAlbums
+ } = albums;
+
+ const {
+ isTrackFilesFetching,
+ isTrackFilesPopulated,
+ trackFilesError,
+ hasTrackFiles
+ } = trackFiles;
+
+ const sortedAlbumTypes = _.orderBy(albumTypes);
+
+ const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist);
+ const nextArtist = sortedArtist[artistIndex + 1] || _.first(sortedArtist);
+ const isArtistRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_ARTIST, artistId: artist.id }));
+ const artistRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_ARTIST });
+ const allArtistRefreshing = (
+ isCommandExecuting(artistRefreshingCommand) &&
+ !artistRefreshingCommand.body.artistId
+ );
+ const isRefreshing = isArtistRefreshing || allArtistRefreshing;
+ const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id }));
+ const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id }));
+
+ const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST });
+ const isRenamingArtist = (
+ isCommandExecuting(isRenamingArtistCommand) &&
+ isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1
+ );
+
+ const isFetching = isAlbumsFetching || isTrackFilesFetching;
+ const isPopulated = isAlbumsPopulated && isTrackFilesPopulated;
+
+ const alternateTitles = _.reduce(artist.alternateTitles, (acc, alternateTitle) => {
+ if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
+ (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
+ acc.push(alternateTitle.title);
+ }
+
+ return acc;
+ }, []);
+
+ return {
+ ...artist,
+ albumTypes: sortedAlbumTypes,
+ alternateTitles,
+ isArtistRefreshing,
+ allArtistRefreshing,
+ isRefreshing,
+ isSearching,
+ isRenamingFiles,
+ isRenamingArtist,
+ isFetching,
+ isPopulated,
+ albumsError,
+ trackFilesError,
+ hasAlbums,
+ hasMonitoredAlbums,
+ hasTrackFiles,
+ previousArtist,
+ nextArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchAlbums,
+ clearAlbums,
+ fetchTrackFiles,
+ clearTrackFiles,
+ toggleArtistMonitored,
+ fetchQueueDetails,
+ clearQueueDetails,
+ executeCommand
+};
+
+class ArtistDetailsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.populate);
+ this.populate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ isArtistRefreshing,
+ allArtistRefreshing,
+ isRenamingFiles,
+ isRenamingArtist
+ } = this.props;
+
+ if (
+ (prevProps.isArtistRefreshing && !isArtistRefreshing) ||
+ (prevProps.allArtistRefreshing && !allArtistRefreshing) ||
+ (prevProps.isRenamingFiles && !isRenamingFiles) ||
+ (prevProps.isRenamingArtist && !isRenamingArtist)
+ ) {
+ this.populate();
+ }
+
+ // If the id has changed we need to clear the albums
+ // files and fetch from the server.
+
+ if (prevProps.id !== id) {
+ this.unpopulate();
+ this.populate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.populate);
+ this.unpopulate();
+ }
+
+ //
+ // Control
+
+ populate = () => {
+ const artistId = this.props.id;
+
+ this.props.fetchAlbums({ artistId });
+ this.props.fetchTrackFiles({ artistId });
+ this.props.fetchQueueDetails({ artistId });
+ }
+
+ unpopulate = () => {
+ this.props.clearAlbums();
+ this.props.clearTrackFiles();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Listeners
+
+ onMonitorTogglePress = (monitored) => {
+ this.props.toggleArtistMonitored({
+ artistId: this.props.id,
+ monitored
+ });
+ }
+
+ onRefreshPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_ARTIST,
+ artistId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.ARTIST_SEARCH,
+ artistId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistDetailsConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ isArtistRefreshing: PropTypes.bool.isRequired,
+ allArtistRefreshing: PropTypes.bool.isRequired,
+ isRefreshing: PropTypes.bool.isRequired,
+ isRenamingFiles: PropTypes.bool.isRequired,
+ isRenamingArtist: PropTypes.bool.isRequired,
+ fetchAlbums: PropTypes.func.isRequired,
+ clearAlbums: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired,
+ toggleArtistMonitored: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsConnector);
diff --git a/frontend/src/Artist/Details/ArtistDetailsLinks.css b/frontend/src/Artist/Details/ArtistDetailsLinks.css
new file mode 100644
index 000000000..d37a082a1
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsLinks.css
@@ -0,0 +1,13 @@
+.links {
+ margin: 0;
+}
+
+.link {
+ white-space: nowrap;
+}
+
+.linkLabel {
+ composes: label from '~Components/Label.css';
+
+ cursor: pointer;
+}
diff --git a/frontend/src/Artist/Details/ArtistDetailsLinks.js b/frontend/src/Artist/Details/ArtistDetailsLinks.js
new file mode 100644
index 000000000..23941d06b
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsLinks.js
@@ -0,0 +1,63 @@
+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 './ArtistDetailsLinks.css';
+
+function ArtistDetailsLinks(props) {
+ const {
+ foreignArtistId,
+ links
+ } = props;
+
+ return (
+
+
+
+
+ Musicbrainz
+
+
+
+ {links.map((link, index) => {
+ return (
+
+
+
+ {link.name}
+
+
+ {(index > 0 && index % 5 === 0) &&
+
+ }
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+ArtistDetailsLinks.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ links: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default ArtistDetailsLinks;
diff --git a/frontend/src/Artist/Details/ArtistDetailsPageConnector.js b/frontend/src/Artist/Details/ArtistDetailsPageConnector.js
new file mode 100644
index 000000000..61b1a0f4c
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsPageConnector.js
@@ -0,0 +1,117 @@
+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 'connected-react-router';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import NotFound from 'Components/NotFound';
+import ArtistDetailsConnector from './ArtistDetailsConnector';
+import styles from './ArtistDetails.css';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ (state) => state.artist,
+ (match, artist) => {
+ const foreignArtistId = match.params.foreignArtistId;
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = artist;
+
+ const artistIndex = _.findIndex(items, { foreignArtistId });
+
+ if (artistIndex > -1) {
+ return {
+ isFetching,
+ isPopulated,
+ foreignArtistId
+ };
+ }
+
+ return {
+ isFetching,
+ isPopulated,
+ error
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ push
+};
+
+class ArtistDetailsPageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.foreignArtistId) {
+ this.props.push(`${window.Lidarr.urlBase}/`);
+ return;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ foreignArtistId,
+ isFetching,
+ isPopulated,
+ error
+ } = this.props;
+
+ if (isFetching && !isPopulated) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!isFetching && !!error) {
+ return (
+
+ {getErrorMessage(error, 'Failed to load artist from API')}
+
+ );
+ }
+
+ if (!foreignArtistId) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistDetailsPageConnector.propTypes = {
+ foreignArtistId: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ match: PropTypes.shape({ params: PropTypes.shape({ foreignArtistId: PropTypes.string.isRequired }).isRequired }).isRequired,
+ push: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsPageConnector);
diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.css b/frontend/src/Artist/Details/ArtistDetailsSeason.css
new file mode 100644
index 000000000..127f0c772
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsSeason.css
@@ -0,0 +1,125 @@
+.albumType {
+ margin-bottom: 20px;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-size: 24px;
+ cursor: pointer;
+}
+
+.albumTypeLabel {
+ margin-right: 5px;
+ margin-left: 5px;
+}
+
+.albumCount {
+ color: #8895aa;
+ font-style: italic;
+ font-size: 18px;
+}
+
+.episodeCountTooltip {
+ display: flex;
+}
+
+.expandButton {
+ composes: link from '~Components/Link/Link.css';
+
+ flex-grow: 1;
+ width: 100%;
+ text-align: center;
+}
+
+.left {
+ display: flex;
+ align-items: center;
+ flex: 0 1 300px;
+}
+
+.left,
+.actions {
+ padding: 15px 10px;
+}
+
+.actionsMenu {
+ composes: menu from '~Components/Menu/Menu.css';
+
+ flex: 0 0 45px;
+}
+
+.actionsMenuContent {
+ composes: menuContent from '~Components/Menu/MenuContent.css';
+
+ white-space: nowrap;
+ font-size: $defaultFontSize;
+}
+
+.actionMenuIcon {
+ margin-right: 8px;
+}
+
+.actionButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ width: 30px;
+}
+
+.albums {
+ padding-top: 15px;
+ border-top: 1px solid $borderColor;
+}
+
+.collapseButtonContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 15px;
+ width: 100%;
+ border-top: 1px solid $borderColor;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ background-color: #fafafa;
+}
+
+.collapseButtonIcon {
+ margin-bottom: -4px;
+}
+
+.expandButtonIcon {
+ composes: actionButton;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -12px;
+ margin-left: -15px;
+}
+
+.noAlbums {
+ margin-bottom: 15px;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .albumType {
+ border-right: 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+
+ .expandButtonIcon {
+ position: static;
+ margin: 0;
+ }
+}
diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.js b/frontend/src/Artist/Details/ArtistDetailsSeason.js
new file mode 100644
index 000000000..f9968a8e9
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsSeason.js
@@ -0,0 +1,253 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getToggledRange from 'Utilities/Table/getToggledRange';
+import { icons, sortDirections } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import AlbumRowConnector from './AlbumRowConnector';
+import styles from './ArtistDetailsSeason.css';
+
+class ArtistDetailsSeason extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isManageTracksOpen: false,
+ lastToggledAlbum: null
+ };
+ }
+
+ componentDidMount() {
+ this._expandByDefault();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ artistId
+ } = this.props;
+
+ if (prevProps.artistId !== artistId) {
+ this._expandByDefault();
+ return;
+ }
+ }
+
+ //
+ // Control
+
+ _expandByDefault() {
+ const {
+ name,
+ onExpandPress,
+ items,
+ uiSettings
+ } = this.props;
+
+ const expand = _.some(items, (item) =>
+ ((item.albumType === 'Album') && uiSettings.expandAlbumByDefault) ||
+ ((item.albumType === 'Single') && uiSettings.expandSingleByDefault) ||
+ ((item.albumType === 'EP') && uiSettings.expandEPByDefault) ||
+ ((item.albumType === 'Broadcast') && uiSettings.expandBroadcastByDefault) ||
+ ((item.albumType === 'Other') && uiSettings.expandOtherByDefault));
+
+ onExpandPress(name, expand);
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onManageTracksPress = () => {
+ this.setState({ isManageTracksOpen: true });
+ }
+
+ onManageTracksModalClose = () => {
+ this.setState({ isManageTracksOpen: false });
+ }
+
+ onExpandPress = () => {
+ const {
+ name,
+ isExpanded
+ } = this.props;
+
+ this.props.onExpandPress(name, !isExpanded);
+ }
+
+ onMonitorAlbumPress = (albumId, monitored, { shiftKey }) => {
+ const lastToggled = this.state.lastToggledAlbum;
+ const albumIds = [albumId];
+
+ if (shiftKey && lastToggled) {
+ const { lower, upper } = getToggledRange(this.props.items, albumId, lastToggled);
+ const items = this.props.items;
+
+ for (let i = lower; i < upper; i++) {
+ albumIds.push(items[i].id);
+ }
+ }
+
+ this.setState({ lastToggledAlbum: albumId });
+
+ this.props.onMonitorAlbumPress(_.uniq(albumIds), monitored);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistId,
+ label,
+ items,
+ columns,
+ isExpanded,
+ sortKey,
+ sortDirection,
+ onSortPress,
+ isSmallScreen,
+ onTableOptionChange
+ } = this.props;
+
+ const {
+ isOrganizeModalOpen,
+ isManageTracksOpen
+ } = this.state;
+
+ return (
+
+
+
+
+ {
+
+
+ {label}
+
+
+
+ ({items.length} Releases)
+
+
+ }
+
+
+
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+
+
+
+ {
+ isExpanded &&
+
+ {
+ items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+
+
+ No releases in this group
+
+ }
+
+
+
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistDetailsSeason.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isExpanded: PropTypes.bool,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onExpandPress: PropTypes.func.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onMonitorAlbumPress: PropTypes.func.isRequired,
+ uiSettings: PropTypes.object.isRequired
+};
+
+export default ArtistDetailsSeason;
diff --git a/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js
new file mode 100644
index 000000000..ffb84ba2c
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js
@@ -0,0 +1,99 @@
+/* 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 createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { toggleAlbumsMonitored, setAlbumsTableOption, setAlbumsSort } from 'Store/Actions/albumActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import ArtistDetailsSeason from './ArtistDetailsSeason';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { label }) => label,
+ createClientSideCollectionSelector('albums'),
+ createArtistSelector(),
+ createCommandsSelector(),
+ createDimensionsSelector(),
+ createUISettingsSelector(),
+ (label, albums, artist, commands, dimensions, uiSettings) => {
+
+ const albumsInGroup = _.filter(albums.items, { albumType: label });
+
+ let sortDir = 'asc';
+
+ if (albums.sortDirection === 'descending') {
+ sortDir = 'desc';
+ }
+
+ const sortedAlbums = _.orderBy(albumsInGroup, albums.sortKey, sortDir);
+
+ return {
+ items: sortedAlbums,
+ columns: albums.columns,
+ sortKey: albums.sortKey,
+ sortDirection: albums.sortDirection,
+ artistMonitored: artist.monitored,
+ isSmallScreen: dimensions.isSmallScreen,
+ uiSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ toggleAlbumsMonitored,
+ setAlbumsTableOption,
+ dispatchSetAlbumSort: setAlbumsSort,
+ executeCommand
+};
+
+class ArtistDetailsSeasonConnector extends Component {
+
+ //
+ // Listeners
+
+ onTableOptionChange = (payload) => {
+ this.props.setAlbumsTableOption(payload);
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.dispatchSetAlbumSort({ sortKey });
+ }
+
+ onMonitorAlbumPress = (albumIds, monitored) => {
+ this.props.toggleAlbumsMonitored({
+ albumIds,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistDetailsSeasonConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ toggleAlbumsMonitored: PropTypes.func.isRequired,
+ setAlbumsTableOption: PropTypes.func.isRequired,
+ dispatchSetAlbumSort: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsSeasonConnector);
diff --git a/frontend/src/Artist/Details/ArtistTags.css b/frontend/src/Artist/Details/ArtistTags.css
new file mode 100644
index 000000000..ec340a041
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistTags.css
@@ -0,0 +1,8 @@
+.tags {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.tag {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Artist/Details/ArtistTags.js b/frontend/src/Artist/Details/ArtistTags.js
new file mode 100644
index 000000000..7ea841a36
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistTags.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 ArtistTags({ tags }) {
+ return (
+
+ {
+ tags.map((tag) => {
+ return (
+
+ {tag}
+
+ );
+ })
+ }
+
+ );
+}
+
+ArtistTags.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default ArtistTags;
diff --git a/frontend/src/Artist/Details/ArtistTagsConnector.js b/frontend/src/Artist/Details/ArtistTagsConnector.js
new file mode 100644
index 000000000..1ecde26cd
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistTagsConnector.js
@@ -0,0 +1,30 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import ArtistTags from './ArtistTags';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createTagsSelector(),
+ (artist, tagList) => {
+ const tags = _.reduce(artist.tags, (acc, tag) => {
+ const matchingTag = _.find(tagList, { id: tag });
+
+ if (matchingTag) {
+ acc.push(matchingTag.label);
+ }
+
+ return acc;
+ }, []);
+
+ return {
+ tags
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ArtistTags);
diff --git a/frontend/src/Artist/Edit/EditArtistModal.js b/frontend/src/Artist/Edit/EditArtistModal.js
new file mode 100644
index 000000000..6e99a2f53
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditArtistModalContentConnector from './EditArtistModalContentConnector';
+
+function EditArtistModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditArtistModal;
diff --git a/frontend/src/Artist/Edit/EditArtistModalConnector.js b/frontend/src/Artist/Edit/EditArtistModalConnector.js
new file mode 100644
index 000000000..9e62a4780
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditArtistModal from './EditArtistModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditArtistModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'artist' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditArtistModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(EditArtistModalConnector);
diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.css b/frontend/src/Artist/Edit/EditArtistModalContent.css
new file mode 100644
index 000000000..a2b6014df
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js
new file mode 100644
index 000000000..73dd652e8
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContent.js
@@ -0,0 +1,210 @@
+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 MoveArtistModal from 'Artist/MoveArtist/MoveArtistModal';
+import styles from './EditArtistModalContent.css';
+
+class EditArtistModalContent 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);
+ }
+ }
+
+ onMoveArtistPress = () => {
+ this.setState({ isConfirmMoveModalOpen: false });
+
+ this.props.onSavePress(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ item,
+ isSaving,
+ showMetadataProfile,
+ originalPath,
+ onInputChange,
+ onModalClose,
+ onDeleteArtistPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ monitored,
+ albumFolder,
+ qualityProfileId,
+ metadataProfileId,
+ path,
+ tags
+ } = item;
+
+ return (
+
+
+ Edit - {artistName}
+
+
+
+
+
+
+
+ Delete
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+
+
+ );
+ }
+}
+
+EditArtistModalContent.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ item: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ isPathChanging: PropTypes.bool.isRequired,
+ originalPath: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteArtistPress: PropTypes.func.isRequired
+};
+
+export default EditArtistModalContent;
diff --git a/frontend/src/Artist/Edit/EditArtistModalContentConnector.js b/frontend/src/Artist/Edit/EditArtistModalContentConnector.js
new file mode 100644
index 000000000..351bc7d34
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContentConnector.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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { setArtistValue, saveArtist } from 'Store/Actions/artistActions';
+import EditArtistModalContent from './EditArtistModalContent';
+
+function createIsPathChangingSelector() {
+ return createSelector(
+ (state) => state.artist.pendingChanges,
+ createArtistSelector(),
+ (pendingChanges, artist) => {
+ const path = pendingChanges.path;
+
+ if (path == null) {
+ return false;
+ }
+
+ return artist.path !== path;
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artist,
+ (state) => state.settings.metadataProfiles,
+ createArtistSelector(),
+ createIsPathChangingSelector(),
+ (artistState, metadataProfiles, artist, isPathChanging) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges
+ } = artistState;
+
+ const artistSettings = _.pick(artist, [
+ 'monitored',
+ 'albumFolder',
+ 'qualityProfileId',
+ 'metadataProfileId',
+ 'path',
+ 'tags'
+ ]);
+
+ const settings = selectSettings(artistSettings, pendingChanges, saveError);
+
+ return {
+ artistName: artist.artistName,
+ isSaving,
+ saveError,
+ isPathChanging,
+ originalPath: artist.path,
+ item: settings.settings,
+ showMetadataProfile: metadataProfiles.items.length > 1,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetArtistValue: setArtistValue,
+ dispatchSaveArtist: saveArtist
+};
+
+class EditArtistModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetArtistValue({ name, value });
+ }
+
+ onSavePress = (moveFiles) => {
+ this.props.dispatchSaveArtist({
+ id: this.props.artistId,
+ moveFiles
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditArtistModalContentConnector.propTypes = {
+ artistId: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ dispatchSetArtistValue: PropTypes.func.isRequired,
+ dispatchSaveArtist: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditArtistModalContentConnector);
diff --git a/frontend/src/Artist/Editor/ArtistEditor.js b/frontend/src/Artist/Editor/ArtistEditor.js
new file mode 100644
index 000000000..d4f6b282c
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditor.js
@@ -0,0 +1,309 @@
+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, 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 NoArtist from 'Artist/NoArtist';
+import OrganizeArtistModal from './Organize/OrganizeArtistModal';
+import RetagArtistModal from './AudioTags/RetagArtistModal';
+import ArtistEditorRowConnector from './ArtistEditorRowConnector';
+import ArtistEditorFooter from './ArtistEditorFooter';
+import ArtistEditorFilterModalConnector from './ArtistEditorFilterModalConnector';
+
+function getColumns(showMetadataProfile) {
+ return [
+ {
+ name: 'status',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'sortName',
+ label: 'Name',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'metadataProfileId',
+ label: 'Metadata Profile',
+ isSortable: true,
+ isVisible: showMetadataProfile
+ },
+ {
+ name: 'albumFolder',
+ label: 'Album Folder',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ isSortable: false,
+ isVisible: true
+ }
+ ];
+}
+
+class ArtistEditor extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isOrganizingArtistModalOpen: false,
+ isRetaggingArtistModalOpen: false,
+ columns: getColumns(props.showMetadataProfile)
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDeleting,
+ deleteError
+ } = this.props;
+
+ const hasFinishedDeleting = prevProps.isDeleting &&
+ !isDeleting &&
+ !deleteError;
+
+ if (hasFinishedDeleting) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSaveSelected = (changes) => {
+ this.props.onSaveSelected({
+ artistIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ onOrganizeArtistPress = () => {
+ this.setState({ isOrganizingArtistModalOpen: true });
+ }
+
+ onOrganizeArtistModalClose = (organized) => {
+ this.setState({ isOrganizingArtistModalOpen: false });
+
+ if (organized === true) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ onRetagArtistPress = () => {
+ this.setState({ isRetaggingArtistModalOpen: true });
+ }
+
+ onRetagArtistModalClose = (organized) => {
+ this.setState({ isRetaggingArtistModalOpen: 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,
+ isOrganizingArtist,
+ isRetaggingArtist,
+ showMetadataProfile,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ columns
+ } = this.state;
+
+ const selectedArtistIds = this.getSelectedIds();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ {getErrorMessage(error, 'Failed to load artist from API')}
+ }
+
+ {
+ !error && isPopulated && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistEditor.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,
+ isOrganizingArtist: PropTypes.bool.isRequired,
+ isRetaggingArtist: PropTypes.bool.isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSaveSelected: PropTypes.func.isRequired
+};
+
+export default ArtistEditor;
diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js
new file mode 100644
index 000000000..c0188ee6d
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorConnector.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 createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import { setArtistEditorSort, setArtistEditorFilter, saveArtistEditor } from 'Store/Actions/artistEditorActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import ArtistEditor from './ArtistEditor';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.metadataProfiles,
+ createClientSideCollectionSelector('artist', 'artistEditor'),
+ createCommandExecutingSelector(commandNames.RENAME_ARTIST),
+ createCommandExecutingSelector(commandNames.RETAG_ARTIST),
+ (metadataProfiles, artist, isOrganizingArtist, isRetaggingArtist) => {
+ return {
+ isOrganizingArtist,
+ isRetaggingArtist,
+ showMetadataProfile: metadataProfiles.items.length > 1,
+ ...artist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetArtistEditorSort: setArtistEditorSort,
+ dispatchSetArtistEditorFilter: setArtistEditorFilter,
+ dispatchSaveArtistEditor: saveArtistEditor,
+ dispatchFetchRootFolders: fetchRootFolders,
+ dispatchExecuteCommand: executeCommand
+};
+
+class ArtistEditorConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.dispatchSetArtistEditorSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.dispatchSetArtistEditorFilter({ selectedFilterKey });
+ }
+
+ onSaveSelected = (payload) => {
+ this.props.dispatchSaveArtistEditor(payload);
+ }
+
+ onMoveSelected = (payload) => {
+ this.props.dispatchExecuteCommand({
+ name: commandNames.MOVE_ARTIST,
+ ...payload
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistEditorConnector.propTypes = {
+ dispatchSetArtistEditorSort: PropTypes.func.isRequired,
+ dispatchSetArtistEditorFilter: PropTypes.func.isRequired,
+ dispatchSaveArtistEditor: PropTypes.func.isRequired,
+ dispatchFetchRootFolders: PropTypes.func.isRequired,
+ dispatchExecuteCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistEditorConnector);
diff --git a/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js b/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js
new file mode 100644
index 000000000..4aff2df06
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistEditorFilter } from 'Store/Actions/artistEditorActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artist.items,
+ (state) => state.artistEditor.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'artistEditor'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setArtistEditorFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.css b/frontend/src/Artist/Editor/ArtistEditorFooter.css
new file mode 100644
index 000000000..3785f88d3
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooter.css
@@ -0,0 +1,70 @@
+.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: $breakpointExtraLarge) {
+ .deleteSelectedButton {
+ margin-left: 0;
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .buttonContainer {
+ justify-content: flex-start;
+ margin-top: 10px;
+ }
+}
+
+@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;
+ }
+
+ .selectedArtistLabel {
+ text-align: left;
+ }
+}
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.js b/frontend/src/Artist/Editor/ArtistEditorFooter.js
new file mode 100644
index 000000000..ccf044c53
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooter.js
@@ -0,0 +1,349 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import SelectInput from 'Components/Form/SelectInput';
+import MetadataProfileSelectInputConnector from 'Components/Form/MetadataProfileSelectInputConnector';
+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 MoveArtistModal from 'Artist/MoveArtist/MoveArtistModal';
+import TagsModal from './Tags/TagsModal';
+import DeleteArtistModal from './Delete/DeleteArtistModal';
+import ArtistEditorFooterLabel from './ArtistEditorFooterLabel';
+import styles from './ArtistEditorFooter.css';
+
+const NO_CHANGE = 'noChange';
+
+class ArtistEditorFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ monitored: NO_CHANGE,
+ qualityProfileId: NO_CHANGE,
+ metadataProfileId: NO_CHANGE,
+ albumFolder: NO_CHANGE,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false,
+ isDeleteArtistModalOpen: 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,
+ metadataProfileId: NO_CHANGE,
+ albumFolder: NO_CHANGE,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+
+ if (value === NO_CHANGE) {
+ return;
+ }
+
+ switch (name) {
+ case 'rootFolderPath':
+ this.setState({
+ isConfirmMoveModalOpen: true,
+ destinationRootFolder: value
+ });
+ break;
+ case 'monitored':
+ this.props.onSaveSelected({ [name]: value === 'monitored' });
+ break;
+ case 'albumFolder':
+ this.props.onSaveSelected({ [name]: value === 'yes' });
+ break;
+ default:
+ this.props.onSaveSelected({ [name]: value });
+ }
+ }
+
+ onApplyTagsPress = (tags, applyTags) => {
+ this.setState({
+ savingTags: true,
+ isTagsModalOpen: false
+ });
+
+ this.props.onSaveSelected({
+ tags,
+ applyTags
+ });
+ }
+
+ onDeleteSelectedPress = () => {
+ this.setState({ isDeleteArtistModalOpen: true });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: 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 });
+ }
+
+ onMoveArtistPress = () => {
+ this.setState({
+ isConfirmMoveModalOpen: false,
+ destinationRootFolder: null
+ });
+
+ this.props.onSaveSelected({
+ rootFolderPath: this.state.destinationRootFolder,
+ moveFiles: true
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistIds,
+ selectedCount,
+ isSaving,
+ isDeleting,
+ isOrganizingArtist,
+ isRetaggingArtist,
+ showMetadataProfile,
+ onOrganizeArtistPress,
+ onRetagArtistPress
+ } = this.props;
+
+ const {
+ monitored,
+ qualityProfileId,
+ metadataProfileId,
+ albumFolder,
+ rootFolderPath,
+ savingTags,
+ isTagsModalOpen,
+ isDeleteArtistModalOpen,
+ isConfirmMoveModalOpen,
+ destinationRootFolder
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored' },
+ { key: 'unmonitored', value: 'Unmonitored' }
+ ];
+
+ const albumFolderOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'yes', value: 'Yes' },
+ { key: 'no', value: 'No' }
+ ];
+
+ return (
+
+
+
+
+
+ {
+ showMetadataProfile &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ Rename Files
+
+
+
+ Write Metadata Tags
+
+
+
+ Set Lidarr Tags
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistEditorFooter.propTypes = {
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ selectedCount: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ isOrganizingArtist: PropTypes.bool.isRequired,
+ isRetaggingArtist: PropTypes.bool.isRequired,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ onSaveSelected: PropTypes.func.isRequired,
+ onOrganizeArtistPress: PropTypes.func.isRequired,
+ onRetagArtistPress: PropTypes.func.isRequired
+};
+
+export default ArtistEditorFooter;
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css
new file mode 100644
index 000000000..9b4b40be6
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css
@@ -0,0 +1,8 @@
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.savingIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js
new file mode 100644
index 000000000..1c6be745d
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.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 './ArtistEditorFooterLabel.css';
+
+function ArtistEditorFooterLabel(props) {
+ const {
+ className,
+ label,
+ isSaving
+ } = props;
+
+ return (
+
+ {label}
+
+ {
+ isSaving &&
+
+ }
+
+ );
+}
+
+ArtistEditorFooterLabel.propTypes = {
+ className: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isSaving: PropTypes.bool.isRequired
+};
+
+ArtistEditorFooterLabel.defaultProps = {
+ className: styles.label
+};
+
+export default ArtistEditorFooterLabel;
diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.css b/frontend/src/Artist/Editor/ArtistEditorRow.css
new file mode 100644
index 000000000..aeb9776ca
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorRow.css
@@ -0,0 +1,5 @@
+.albumFolder {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.js b/frontend/src/Artist/Editor/ArtistEditorRow.js
new file mode 100644
index 000000000..cfead73be
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorRow.js
@@ -0,0 +1,120 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TagListConnector from 'Components/TagListConnector';
+import CheckInput from 'Components/Form/CheckInput';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell';
+import styles from './ArtistEditorRow.css';
+
+class ArtistEditorRow extends Component {
+
+ //
+ // Listeners
+
+ onAlbumFolderChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ status,
+ foreignArtistId,
+ artistName,
+ artistType,
+ monitored,
+ metadataProfile,
+ qualityProfile,
+ albumFolder,
+ path,
+ tags,
+ columns,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {qualityProfile.name}
+
+
+ {
+ _.find(columns, { name: 'metadataProfileId' }).isVisible &&
+
+ {metadataProfile.name}
+
+ }
+
+
+
+
+
+
+ {path}
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ status: PropTypes.string.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ artistType: PropTypes.string,
+ monitored: PropTypes.bool.isRequired,
+ metadataProfile: PropTypes.object.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ albumFolder: PropTypes.bool.isRequired,
+ path: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+ArtistEditorRow.defaultProps = {
+ tags: []
+};
+
+export default ArtistEditorRow;
diff --git a/frontend/src/Artist/Editor/ArtistEditorRowConnector.js b/frontend/src/Artist/Editor/ArtistEditorRowConnector.js
new file mode 100644
index 000000000..32694a6b9
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorRowConnector.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createMetadataProfileSelector from 'Store/Selectors/createMetadataProfileSelector';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+import ArtistEditorRow from './ArtistEditorRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMetadataProfileSelector(),
+ createQualityProfileSelector(),
+ (metadataProfile, qualityProfile) => {
+ return {
+ metadataProfile,
+ qualityProfile
+ };
+ }
+ );
+}
+
+function ArtistEditorRowConnector(props) {
+ return (
+
+ );
+}
+
+ArtistEditorRowConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps)(ArtistEditorRowConnector);
diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js
new file mode 100644
index 000000000..636ca6618
--- /dev/null
+++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import RetagArtistModalContentConnector from './RetagArtistModalContentConnector';
+
+function RetagArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+RetagArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RetagArtistModal;
diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css
new file mode 100644
index 000000000..02c52edc8
--- /dev/null
+++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css
@@ -0,0 +1,8 @@
+.retagIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js
new file mode 100644
index 000000000..015112556
--- /dev/null
+++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js
@@ -0,0 +1,73 @@
+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 './RetagArtistModalContent.css';
+
+function RetagArtistModalContent(props) {
+ const {
+ artistNames,
+ onModalClose,
+ onRetagArtistPress
+ } = props;
+
+ return (
+
+
+ Retag Selected Artist
+
+
+
+
+ Tip: To preview the tags that will be written... select "Cancel" then click any artist name and use the
+
+
+
+
+ Are you sure you want to re-tag all files in the {artistNames.length} selected artist?
+
+
+ {
+ artistNames.map((artistName) => {
+ return (
+
+ {artistName}
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Retag
+
+
+
+ );
+}
+
+RetagArtistModalContent.propTypes = {
+ artistNames: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onRetagArtistPress: PropTypes.func.isRequired
+};
+
+export default RetagArtistModalContent;
diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js
new file mode 100644
index 000000000..1c104db00
--- /dev/null
+++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.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 createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import RetagArtistModalContent from './RetagArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ (artistIds, allArtists) => {
+ const artist = _.intersectionWith(allArtists, artistIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedArtist = _.orderBy(artist, 'sortName');
+ const artistNames = _.map(sortedArtist, 'artistName');
+
+ return {
+ artistNames
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class RetagArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onRetagArtistPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RETAG_ARTIST,
+ artistIds: this.props.artistIds
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render(props) {
+ return (
+
+ );
+ }
+}
+
+RetagArtistModalContentConnector.propTypes = {
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RetagArtistModalContentConnector);
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js
new file mode 100644
index 000000000..11fd79d5d
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
+
+function DeleteArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModal;
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css
new file mode 100644
index 000000000..950fdc27d
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css
@@ -0,0 +1,13 @@
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.pathContainer {
+ margin-left: 5px;
+}
+
+.path {
+ margin-left: 5px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js
new file mode 100644
index 000000000..87088b472
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './DeleteArtistModalContent.css';
+
+class DeleteArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onDeleteArtistConfirmed = () => {
+ const deleteFiles = this.state.deleteFiles;
+
+ this.setState({ deleteFiles: false });
+ this.props.onDeleteSelectedPress(deleteFiles);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artist,
+ onModalClose
+ } = this.props;
+ const deleteFiles = this.state.deleteFiles;
+
+ return (
+
+
+ Delete Selected Artist
+
+
+
+
+
+ {`Delete Artist Folder${artist.length > 1 ? 's' : ''}`}
+
+ 1 ? 's' : ''} and all contents`}
+ kind={kinds.DANGER}
+ onChange={this.onDeleteFilesChange}
+ />
+
+
+
+
+ {`Are you sure you want to delete ${artist.length} selected artist${artist.length > 1 ? 's' : ''}${deleteFiles ? ' and all contents' : ''}?`}
+
+
+
+ {
+ artist.map((s) => {
+ return (
+
+ {s.artistName}
+
+ {
+ deleteFiles &&
+
+ -
+
+ {s.path}
+
+
+ }
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteArtistModalContent.propTypes = {
+ artist: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteSelectedPress: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModalContent;
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js
new file mode 100644
index 000000000..8c61976e8
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js
@@ -0,0 +1,45 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import { bulkDeleteArtist } from 'Store/Actions/artistEditorActions';
+import DeleteArtistModalContent from './DeleteArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ (artistIds, allArtists) => {
+ const selectedArtist = _.intersectionWith(allArtists, artistIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedArtist = _.orderBy(selectedArtist, 'sortName');
+ const artist = _.map(sortedArtist, (s) => {
+ return {
+ artistName: s.artistName,
+ path: s.path
+ };
+ });
+
+ return {
+ artist
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDeleteSelectedPress(deleteFiles) {
+ dispatch(bulkDeleteArtist({
+ artistIds: props.artistIds,
+ deleteFiles
+ }));
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteArtistModalContent);
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js
new file mode 100644
index 000000000..412396355
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import OrganizeArtistModalContentConnector from './OrganizeArtistModalContentConnector';
+
+function OrganizeArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+OrganizeArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizeArtistModal;
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css
new file mode 100644
index 000000000..0b896f4ef
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css
@@ -0,0 +1,8 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js
new file mode 100644
index 000000000..5f90eca90
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.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 './OrganizeArtistModalContent.css';
+
+function OrganizeArtistModalContent(props) {
+ const {
+ artistNames,
+ onModalClose,
+ onOrganizeArtistPress
+ } = props;
+
+ return (
+
+
+ Organize Selected Artist
+
+
+
+
+ Tip: To preview a rename... select "Cancel" then click any artist name and use the
+
+
+
+
+ Are you sure you want to organize all files in the {artistNames.length} selected artist?
+
+
+
+ {
+ artistNames.map((artistName) => {
+ return (
+
+ {artistName}
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Organize
+
+
+
+ );
+}
+
+OrganizeArtistModalContent.propTypes = {
+ artistNames: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onOrganizeArtistPress: PropTypes.func.isRequired
+};
+
+export default OrganizeArtistModalContent;
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js
new file mode 100644
index 000000000..6be1eb961
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.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 createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import OrganizeArtistModalContent from './OrganizeArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ (artistIds, allArtists) => {
+ const artist = _.intersectionWith(allArtists, artistIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedArtist = _.orderBy(artist, 'sortName');
+ const artistNames = _.map(sortedArtist, 'artistName');
+
+ return {
+ artistNames
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class OrganizeArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onOrganizeArtistPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RENAME_ARTIST,
+ artistIds: this.props.artistIds
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render(props) {
+ return (
+
+ );
+ }
+}
+
+OrganizeArtistModalContentConnector.propTypes = {
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeArtistModalContentConnector);
diff --git a/frontend/src/Artist/Editor/Tags/TagsModal.js b/frontend/src/Artist/Editor/Tags/TagsModal.js
new file mode 100644
index 000000000..0f6c2d7ec
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import TagsModalContentConnector from './TagsModalContentConnector';
+
+function TagsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+TagsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TagsModal;
diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.css b/frontend/src/Artist/Editor/Tags/TagsModalContent.css
new file mode 100644
index 000000000..63be9aadd
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModalContent.css
@@ -0,0 +1,12 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.result {
+ padding-top: 4px;
+}
diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.js b/frontend/src/Artist/Editor/Tags/TagsModalContent.js
new file mode 100644
index 000000000..b982fee0e
--- /dev/null
+++ b/frontend/src/Artist/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 {
+ artistTags,
+ 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 = {
+ artistTags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onApplyTagsPress: PropTypes.func.isRequired
+};
+
+export default TagsModalContent;
diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js
new file mode 100644
index 000000000..6741e8b5c
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagsModalContent from './TagsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ createTagsSelector(),
+ (artistIds, allArtists, tagList) => {
+ const artist = _.intersectionWith(allArtists, artistIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const artistTags = _.uniq(_.concat(..._.map(artist, 'tags')));
+
+ return {
+ artistTags,
+ tagList
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onAction() {
+ // Do something
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent);
diff --git a/frontend/src/Artist/History/ArtistHistoryModal.js b/frontend/src/Artist/History/ArtistHistoryModal.js
new file mode 100644
index 000000000..7139d7633
--- /dev/null
+++ b/frontend/src/Artist/History/ArtistHistoryModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistHistoryModalContentConnector from './ArtistHistoryModalContentConnector';
+
+function ArtistHistoryModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+ArtistHistoryModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistHistoryModal;
diff --git a/frontend/src/Artist/History/ArtistHistoryModalContent.js b/frontend/src/Artist/History/ArtistHistoryModalContent.js
new file mode 100644
index 000000000..9be74ba40
--- /dev/null
+++ b/frontend/src/Artist/History/ArtistHistoryModalContent.js
@@ -0,0 +1,132 @@
+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 ArtistHistoryRowConnector from './ArtistHistoryRowConnector';
+
+const columns = [
+ {
+ name: 'eventType',
+ isVisible: true
+ },
+ {
+ name: 'album',
+ label: 'Album',
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isVisible: true
+ },
+ {
+ name: 'details',
+ label: 'Details',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: 'Actions',
+ isVisible: true
+ }
+];
+
+class ArtistHistoryModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ albumId,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onMarkAsFailedPress,
+ onModalClose
+ } = this.props;
+
+ const fullArtist = albumId == 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
+
+
+
+ );
+ }
+}
+
+ArtistHistoryModalContent.propTypes = {
+ albumId: 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 ArtistHistoryModalContent;
diff --git a/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js b/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js
new file mode 100644
index 000000000..a989361f5
--- /dev/null
+++ b/frontend/src/Artist/History/ArtistHistoryModalContentConnector.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 { fetchArtistHistory, clearArtistHistory, artistHistoryMarkAsFailed } from 'Store/Actions/artistHistoryActions';
+import ArtistHistoryModalContent from './ArtistHistoryModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistHistory,
+ (artistHistory) => {
+ return artistHistory;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchArtistHistory,
+ clearArtistHistory,
+ artistHistoryMarkAsFailed
+};
+
+class ArtistHistoryModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId,
+ albumId
+ } = this.props;
+
+ this.props.fetchArtistHistory({
+ artistId,
+ albumId
+ });
+ }
+
+ componentWillUnmount() {
+ this.props.clearArtistHistory();
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = (historyId) => {
+ const {
+ artistId,
+ albumId
+ } = this.props;
+
+ this.props.artistHistoryMarkAsFailed({
+ historyId,
+ artistId,
+ albumId
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistHistoryModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number,
+ fetchArtistHistory: PropTypes.func.isRequired,
+ clearArtistHistory: PropTypes.func.isRequired,
+ artistHistoryMarkAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryModalContentConnector);
diff --git a/frontend/src/Artist/History/ArtistHistoryRow.css b/frontend/src/Artist/History/ArtistHistoryRow.css
new file mode 100644
index 000000000..deafecb81
--- /dev/null
+++ b/frontend/src/Artist/History/ArtistHistoryRow.css
@@ -0,0 +1,6 @@
+.details,
+.actions {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 65px;
+}
diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js
new file mode 100644
index 000000000..e69f8395b
--- /dev/null
+++ b/frontend/src/Artist/History/ArtistHistoryRow.js
@@ -0,0 +1,170 @@
+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 TrackQuality from 'Album/TrackQuality';
+import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
+import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
+import styles from './ArtistHistoryRow.css';
+
+function getTitle(eventType) {
+ switch (eventType) {
+ case 'grabbed':
+ return 'Grabbed';
+ case 'downloadImported':
+ return 'Download Completed';
+ case 'trackFileImported':
+ return 'Track Imported';
+ case 'downloadFailed':
+ return 'Download Failed';
+ case 'trackFileDeleted':
+ return 'Track File Deleted';
+ case 'trackFileRenamed':
+ return 'Track File Renamed';
+ case 'trackFileRetagged':
+ return 'Track File Tags Updated';
+ case 'albumImportIncomplete':
+ return 'Album Import Incomplete';
+ default:
+ return 'Unknown';
+ }
+}
+
+class ArtistHistoryRow 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,
+ album
+ } = this.props;
+
+ const {
+ isMarkAsFailedModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+ {album.title}
+
+
+
+ {sourceTitle}
+
+
+
+
+
+
+
+
+
+
+ }
+ title={getTitle(eventType)}
+ body={
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+ArtistHistoryRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ fullArtist: PropTypes.bool.isRequired,
+ artist: PropTypes.object.isRequired,
+ album: PropTypes.object.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+export default ArtistHistoryRow;
diff --git a/frontend/src/Artist/History/ArtistHistoryRowConnector.js b/frontend/src/Artist/History/ArtistHistoryRowConnector.js
new file mode 100644
index 000000000..2bcfc7cb6
--- /dev/null
+++ b/frontend/src/Artist/History/ArtistHistoryRowConnector.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createAlbumSelector from 'Store/Selectors/createAlbumSelector';
+import ArtistHistoryRow from './ArtistHistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createAlbumSelector(),
+ (artist, album) => {
+ return {
+ artist,
+ album
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHistory,
+ markAsFailed
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryRow);
diff --git a/frontend/src/Artist/Index/ArtistIndex.css b/frontend/src/Artist/Index/ArtistIndex.css
new file mode 100644
index 000000000..43b445c3c
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndex.css
@@ -0,0 +1,72 @@
+.pageContentBodyWrapper {
+ display: flex;
+ flex: 1 0 1px;
+ overflow: hidden;
+}
+
+.errorMessage {
+ margin-top: 20px;
+ text-align: center;
+ font-size: 20px;
+}
+
+.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);
+}
+
+.bannersInnerContentBody {
+ 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);
+ }
+
+ .bannersInnerContentBody {
+ padding: calc($pageContentBodyPaddingSmallScreen - 5px);
+ }
+}
diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js
new file mode 100644
index 000000000..f88ffda52
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndex.js
@@ -0,0 +1,429 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+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 TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import NoArtist from 'Artist/NoArtist';
+import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector';
+import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector';
+import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
+import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector';
+import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal';
+import ArtistIndexBannersConnector from './Banners/ArtistIndexBannersConnector';
+import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal';
+import ArtistIndexOverviewsConnector from './Overview/ArtistIndexOverviewsConnector';
+import ArtistIndexFooterConnector from './ArtistIndexFooterConnector';
+import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu';
+import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu';
+import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu';
+import styles from './ArtistIndex.css';
+
+function getViewComponent(view) {
+ if (view === 'posters') {
+ return ArtistIndexPostersConnector;
+ }
+
+ if (view === 'banners') {
+ return ArtistIndexBannersConnector;
+ }
+
+ if (view === 'overview') {
+ return ArtistIndexOverviewsConnector;
+ }
+
+ return ArtistIndexTableConnector;
+}
+
+class ArtistIndex extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ contentBody: null,
+ jumpBarItems: [],
+ jumpToCharacter: null,
+ isPosterOptionsModalOpen: false,
+ isBannerOptionsModalOpen: 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 sortName
+ if (sortKey !== 'sortName') {
+ this.setState({ jumpBarItems: [] });
+ return;
+ }
+
+ const characters = _.reduce(items, (acc, item) => {
+ const firstCharacter = item.sortName.charAt(0);
+
+ if (isNaN(firstCharacter)) {
+ acc.push(firstCharacter);
+ } else {
+ acc.push('#');
+ }
+
+ return acc;
+ }, []).sort();
+
+ // Reverse if sorting descending
+ if (sortDirection === sortDirections.DESCENDING) {
+ characters.reverse();
+ }
+
+ this.setState({ jumpBarItems: _.sortedUniq(characters) });
+ }
+
+ //
+ // Listeners
+
+ onPosterOptionsPress = () => {
+ this.setState({ isPosterOptionsModalOpen: true });
+ }
+
+ onPosterOptionsModalClose = () => {
+ this.setState({ isPosterOptionsModalOpen: false });
+ }
+
+ onBannerOptionsPress = () => {
+ this.setState({ isBannerOptionsModalOpen: true });
+ }
+
+ onBannerOptionsModalClose = () => {
+ this.setState({ isBannerOptionsModalOpen: 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,
+ columns,
+ selectedFilterKey,
+ filters,
+ customFilters,
+ sortKey,
+ sortDirection,
+ view,
+ isRefreshingArtist,
+ isRssSyncExecuting,
+ scrollTop,
+ onSortSelect,
+ onFilterSelect,
+ onViewSelect,
+ onRefreshArtistPress,
+ onRssSyncPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ contentBody,
+ jumpBarItems,
+ jumpToCharacter,
+ isPosterOptionsModalOpen,
+ isBannerOptionsModalOpen,
+ isOverviewOptionsModalOpen,
+ isRendered
+ } = this.state;
+
+ const ViewComponent = getViewComponent(view);
+ const isLoaded = !!(!error && isPopulated && items.length && contentBody);
+ const hasNoArtist = !totalItems;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ view === 'table' ?
+
+
+ :
+ null
+ }
+
+ {
+ view === 'posters' ?
+ :
+ null
+ }
+
+ {
+ view === 'banners' ?
+ :
+ null
+ }
+
+ {
+ view === 'overview' ?
+ :
+ null
+ }
+
+ {
+ (view === 'posters' || view === 'banners' || view === 'overview') &&
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+ {getErrorMessage(error, 'Failed to load artist from API')}
+
+ }
+
+ {
+ isLoaded &&
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+ {
+ isLoaded && !!jumpBarItems.length &&
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndex.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ totalItems: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: 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,
+ isRefreshingArtist: 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,
+ onRefreshArtistPress: PropTypes.func.isRequired,
+ onRssSyncPress: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndex;
diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js
new file mode 100644
index 000000000..a9e0b7dcc
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexConnector.js
@@ -0,0 +1,162 @@
+/* eslint max-params: 0 */
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector';
+import dimensions from 'Styles/Variables/dimensions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import scrollPositions from 'Store/scrollPositions';
+import { setArtistSort, setArtistFilter, setArtistView, setArtistTableOption } from 'Store/Actions/artistIndexActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import withScrollPosition from 'Components/withScrollPosition';
+import ArtistIndex from './ArtistIndex';
+
+const POSTERS_PADDING = 15;
+const POSTERS_PADDING_SMALL_SCREEN = 5;
+const BANNERS_PADDING = 15;
+const BANNERS_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;
+ }
+
+ if (view === 'banners') {
+ padding = isSmallScreen ? BANNERS_PADDING_SMALL_SCREEN : BANNERS_PADDING;
+ }
+
+ return scrollTop + padding;
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistClientSideCollectionItemsSelector('artistIndex'),
+ createCommandExecutingSelector(commandNames.REFRESH_ARTIST),
+ createCommandExecutingSelector(commandNames.RSS_SYNC),
+ createDimensionsSelector(),
+ (
+ artist,
+ isRefreshingArtist,
+ isRssSyncExecuting,
+ dimensionsState
+ ) => {
+ return {
+ ...artist,
+ isRefreshingArtist,
+ isRssSyncExecuting,
+ isSmallScreen: dimensionsState.isSmallScreen
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setArtistTableOption(payload));
+ },
+
+ onSortSelect(sortKey) {
+ dispatch(setArtistSort({ sortKey }));
+ },
+
+ onFilterSelect(selectedFilterKey) {
+ dispatch(setArtistFilter({ selectedFilterKey }));
+ },
+
+ dispatchSetArtistView(view) {
+ dispatch(setArtistView({ view }));
+ },
+
+ onRefreshArtistPress() {
+ dispatch(executeCommand({
+ name: commandNames.REFRESH_ARTIST
+ }));
+ },
+
+ onRssSyncPress() {
+ dispatch(executeCommand({
+ name: commandNames.RSS_SYNC
+ }));
+ }
+ };
+}
+
+class ArtistIndexConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ view,
+ scrollTop,
+ isSmallScreen
+ } = props;
+
+ this.state = {
+ scrollTop: getScrollTop(view, scrollTop, isSmallScreen)
+ };
+ }
+
+ //
+ // Listeners
+
+ onViewSelect = (view) => {
+ // Reset the scroll position before changing the view
+ this.setState({ scrollTop: 0 }, () => {
+ this.props.dispatchSetArtistView(view);
+ });
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.setState({
+ scrollTop
+ }, () => {
+ scrollPositions.artistIndex = scrollTop;
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistIndexConnector.propTypes = {
+ isSmallScreen: PropTypes.bool.isRequired,
+ view: PropTypes.string.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ dispatchSetArtistView: PropTypes.func.isRequired
+};
+
+export default withScrollPosition(
+ connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector),
+ 'artistIndex'
+);
diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js
new file mode 100644
index 000000000..412f3df34
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistFilter } from 'Store/Actions/artistIndexActions';
+import FilterModal from 'Components/Filter/FilterModal';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artist.items,
+ (state) => state.artistIndex.filterBuilderProps,
+ (sectionItems, filterBuilderProps) => {
+ return {
+ sectionItems,
+ filterBuilderProps,
+ customFilterType: 'artistIndex'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetFilter: setArtistFilter
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.css b/frontend/src/Artist/Index/ArtistIndexFooter.css
new file mode 100644
index 000000000..71d0439b6
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFooter.css
@@ -0,0 +1,74 @@
+.footer {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 20px;
+ font-size: $smallFontSize;
+}
+
+.legendItem {
+ display: flex;
+ margin-bottom: 4px;
+ line-height: 16px;
+}
+
+.legendItemColor {
+ margin-right: 8px;
+ width: 30px;
+ height: 16px;
+ border-radius: 4px;
+}
+
+.continuing {
+ composes: legendItemColor;
+
+ background-color: $primaryColor;
+}
+
+.ended {
+ composes: legendItemColor;
+
+ background-color: $successColor;
+}
+
+.missingMonitored {
+ composes: legendItemColor;
+
+ background-color: $dangerColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px);
+ }
+}
+
+.missingUnmonitored {
+ composes: legendItemColor;
+
+ background-color: $warningColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
+ }
+}
+
+.statistics {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+
+@media (max-width: $breakpointLarge) {
+ .statistics {
+ display: block;
+ }
+}
+
+@media (max-width: $breakpointSmall) {
+ .footer {
+ display: block;
+ }
+
+ .statistics {
+ display: flex;
+ margin-top: 20px;
+ }
+}
diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js
new file mode 100644
index 000000000..245312ae6
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFooter.js
@@ -0,0 +1,158 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './ArtistIndexFooter.css';
+
+class ArtistIndexFooter extends PureComponent {
+
+ //
+ // Render
+
+ render() {
+ const { artist } = this.props;
+ const count = artist.length;
+ let tracks = 0;
+ let trackFiles = 0;
+ let ended = 0;
+ let continuing = 0;
+ let monitored = 0;
+ let totalFileSize = 0;
+
+ artist.forEach((s) => {
+ const { statistics = {} } = s;
+
+ const {
+ trackCount = 0,
+ trackFileCount = 0,
+ sizeOnDisk = 0
+ } = statistics;
+
+ tracks += trackCount;
+ trackFiles += trackFileCount;
+
+ if (s.status === 'ended') {
+ ended++;
+ } else {
+ continuing++;
+ }
+
+ if (s.monitored) {
+ monitored++;
+ }
+
+ totalFileSize += sizeOnDisk;
+ });
+
+ return (
+
+ {(enableColorImpairedMode) => {
+ return (
+
+
+
+
+
Continuing (All tracks downloaded)
+
+
+
+
+
Ended (All tracks downloaded)
+
+
+
+
+
Missing Tracks (Artist monitored)
+
+
+
+
+
Missing Tracks (Artist not monitored)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }}
+
+ );
+ }
+}
+
+ArtistIndexFooter.propTypes = {
+ artist: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default ArtistIndexFooter;
diff --git a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js b/frontend/src/Artist/Index/ArtistIndexFooterConnector.js
new file mode 100644
index 000000000..9d7afc298
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFooterConnector.js
@@ -0,0 +1,46 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import ArtistIndexFooter from './ArtistIndexFooter';
+
+function createUnoptimizedSelector() {
+ return createSelector(
+ createClientSideCollectionSelector('artist', 'artistIndex'),
+ (artist) => {
+ return artist.items.map((s) => {
+ const {
+ monitored,
+ status,
+ statistics
+ } = s;
+
+ return {
+ monitored,
+ status,
+ statistics
+ };
+ });
+ }
+ );
+}
+
+function createArtistSelector() {
+ return createDeepEqualSelector(
+ createUnoptimizedSelector(),
+ (artist) => artist
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ArtistIndexFooter);
diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js
new file mode 100644
index 000000000..aef6a8e5e
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexItemConnector.js
@@ -0,0 +1,141 @@
+/* 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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
+import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector';
+import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+
+function selectShowSearchAction() {
+ return createSelector(
+ (state) => state.artistIndex,
+ (artistIndex) => {
+ const view = artistIndex.view;
+
+ switch (view) {
+ case 'posters':
+ return artistIndex.posterOptions.showSearchAction;
+ case 'banners':
+ return artistIndex.bannerOptions.showSearchAction;
+ case 'overview':
+ return artistIndex.overviewOptions.showSearchAction;
+ default:
+ return artistIndex.tableOptions.showSearchAction;
+ }
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createArtistQualityProfileSelector(),
+ createArtistMetadataProfileSelector(),
+ selectShowSearchAction(),
+ createExecutingCommandsSelector(),
+ (
+ artist,
+ qualityProfile,
+ metadataProfile,
+ showSearchAction,
+ executingCommands
+ ) => {
+
+ // If an artist is deleted this selector may fire before the parent
+ // selectors, which will result in an undefined artist, if that happens
+ // we want to return early here and again in the render function to avoid
+ // trying to show an artist that has no information available.
+
+ if (!artist) {
+ return {};
+ }
+
+ const isRefreshingArtist = executingCommands.some((command) => {
+ return (
+ command.name === commandNames.REFRESH_ARTIST &&
+ command.body.artistId === artist.id
+ );
+ });
+
+ const isSearchingArtist = executingCommands.some((command) => {
+ return (
+ command.name === commandNames.ARTIST_SEARCH &&
+ command.body.artistId === artist.id
+ );
+ });
+
+ const latestAlbum = _.maxBy(artist.albums, (album) => album.releaseDate);
+
+ return {
+ ...artist,
+ qualityProfile,
+ metadataProfile,
+ latestAlbum,
+ showSearchAction,
+ isRefreshingArtist,
+ isSearchingArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchExecuteCommand: executeCommand
+};
+
+class ArtistIndexItemConnector extends Component {
+
+ //
+ // Listeners
+
+ onRefreshArtistPress = () => {
+ this.props.dispatchExecuteCommand({
+ name: commandNames.REFRESH_ARTIST,
+ artistId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.dispatchExecuteCommand({
+ name: commandNames.ARTIST_SEARCH,
+ artistId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ component: ItemComponent,
+ ...otherProps
+ } = this.props;
+
+ if (!id) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistIndexItemConnector.propTypes = {
+ id: PropTypes.number,
+ component: PropTypes.elementType.isRequired,
+ dispatchExecuteCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector);
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css
new file mode 100644
index 000000000..3f9bfdd8b
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css
@@ -0,0 +1,85 @@
+$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;
+ }
+ }
+}
+
+.bannerContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from '~Components/Link/Link.css';
+
+ display: block;
+ background-color: $defaultColor;
+}
+
+.nextAiring {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.title {
+ @add-mixin truncate;
+
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.controls {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ z-index: 3;
+ border-radius: 4px;
+ background-color: #216044;
+ color: $white;
+ font-size: $smallFontSize;
+ opacity: 0;
+ transition: opacity 0;
+}
+
+.action {
+ composes: button from '~Components/Link/IconButton.css';
+
+ &:hover {
+ color: #ccc;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .container {
+ padding: 5px;
+ }
+}
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js
new file mode 100644
index 000000000..42883da51
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js
@@ -0,0 +1,272 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import ArtistBanner from 'Artist/ArtistBanner';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
+import ArtistIndexBannerInfo from './ArtistIndexBannerInfo';
+import styles from './ArtistIndexBanner.css';
+
+class ArtistIndexBanner extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ artistName,
+ monitored,
+ status,
+ foreignArtistId,
+ nextAiring,
+ statistics,
+ images,
+ bannerWidth,
+ bannerHeight,
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ showSearchAction,
+ qualityProfile,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isRefreshingArtist,
+ isSearchingArtist,
+ onRefreshArtistPress,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ albumCount,
+ sizeOnDisk,
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ } = statistics;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const link = `/artist/${foreignArtistId}`;
+
+ const elementStyle = {
+ width: `${bannerWidth}px`,
+ height: `${bannerHeight}px`
+ };
+
+ return (
+
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+ {
+ showTitle &&
+
+ {artistName}
+
+ }
+
+ {
+ showMonitored &&
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+ }
+
+ {
+ showQualityProfile &&
+
+ {qualityProfile.name}
+
+ }
+ {
+ nextAiring &&
+
+ {
+ getRelativeDate(
+ nextAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexBanner.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ bannerWidth: PropTypes.number.isRequired,
+ bannerHeight: 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,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ isSearchingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+ArtistIndexBanner.defaultProps = {
+ statistics: {
+ albumCount: 0,
+ trackCount: 0,
+ trackFileCount: 0,
+ totalTrackCount: 0
+ }
+};
+
+export default ArtistIndexBanner;
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css
new file mode 100644
index 000000000..aab27d827
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css
@@ -0,0 +1,5 @@
+.info {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js
new file mode 100644
index 000000000..f641de0e1
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import styles from './ArtistIndexBannerInfo.css';
+
+function ArtistIndexBannerInfo(props) {
+ const {
+ qualityProfile,
+ showQualityProfile,
+ previousAiring,
+ added,
+ albumCount,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (sortKey === 'qualityProfileId' && !showQualityProfile) {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (sortKey === 'previousAiring' && previousAiring) {
+ return (
+
+ {
+ getRelativeDate(
+ previousAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ );
+ }
+
+ if (sortKey === 'added' && added) {
+ const addedDate = getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: false
+ }
+ );
+
+ return (
+
+ {`Added ${addedDate}`}
+
+ );
+ }
+
+ if (sortKey === 'albumCount') {
+ let albums = '1 album';
+
+ if (albumCount === 0) {
+ albums = 'No albums';
+ } else if (albumCount > 1) {
+ albums = `${albumCount} albums`;
+ }
+
+ return (
+
+ {albums}
+
+ );
+ }
+
+ if (sortKey === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (sortKey === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ return null;
+}
+
+ArtistIndexBannerInfo.propTypes = {
+ qualityProfile: PropTypes.object.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default ArtistIndexBannerInfo;
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js
new file mode 100644
index 000000000..28cfdf14c
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js
@@ -0,0 +1,326 @@
+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 ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexBanner from './ArtistIndexBanner';
+import styles from './ArtistIndexBanners.css';
+
+// container dimensions
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+const additionalColumnCount = {
+ small: 3,
+ medium: 2,
+ large: 1
+};
+
+function calculateColumnWidth(width, bannerSize, isSmallScreen) {
+ const maxiumColumnWidth = isSmallScreen ? 344 : 364;
+ const columns = Math.floor(width / maxiumColumnWidth);
+ const remainder = width % maxiumColumnWidth;
+
+ if (remainder === 0 && bannerSize === 'large') {
+ return maxiumColumnWidth;
+ }
+
+ return Math.floor(width / (columns + additionalColumnCount[bannerSize]));
+}
+
+function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) {
+ const {
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile
+ } = bannerOptions;
+
+ const nextAiringHeight = 19;
+
+ const heights = [
+ bannerHeight,
+ 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 'seasons':
+ case 'previousAiring':
+ case 'added':
+ case 'path':
+ case 'sizeOnDisk':
+ heights.push(19);
+ break;
+ case 'qualityProfileId':
+ if (!showQualityProfile) {
+ heights.push(19);
+ }
+ break;
+ default:
+ // No need to add a height of 0
+ }
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculateHeight(bannerWidth) {
+ return Math.ceil((88/476) * bannerWidth);
+}
+
+class ArtistIndexBanners extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnWidth: 364,
+ columnCount: 1,
+ bannerWidth: 476,
+ bannerHeight: 88,
+ rowHeight: calculateRowHeight(88, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filters,
+ sortKey,
+ sortDirection,
+ bannerOptions,
+ jumpToCharacter
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.bannerOptions !== bannerOptions ||
+ 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,
+ bannerOptions
+ } = this.props;
+
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+ const columnWidth = calculateColumnWidth(width, bannerOptions.size, isSmallScreen);
+ const columnCount = Math.max(Math.floor(width / columnWidth), 1);
+ const bannerWidth = columnWidth - padding;
+ const bannerHeight = calculateHeight(bannerWidth);
+ const rowHeight = calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions);
+
+ this.setState({
+ width,
+ columnWidth,
+ columnCount,
+ bannerWidth,
+ bannerHeight,
+ rowHeight
+ });
+ }
+
+ cellRenderer = ({ key, rowIndex, columnIndex, style }) => {
+ const {
+ items,
+ sortKey,
+ bannerOptions,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = this.props;
+
+ const {
+ bannerWidth,
+ bannerHeight,
+ columnCount
+ } = this.state;
+
+ const {
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile
+ } = bannerOptions;
+
+ const artist = items[rowIndex * columnCount + columnIndex];
+
+ if (!artist) {
+ 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 (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+ArtistIndexBanners.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ bannerOptions: 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 ArtistIndexBanners;
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js
new file mode 100644
index 000000000..bac56ebd2
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import ArtistIndexBanners from './ArtistIndexBanners';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex.bannerOptions,
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (bannerOptions, uiSettings, dimensions) => {
+ return {
+ bannerOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ArtistIndexBanners);
diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js
new file mode 100644
index 000000000..34c8abfcf
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistIndexBannerOptionsModalContentConnector from './ArtistIndexBannerOptionsModalContentConnector';
+
+function ArtistIndexBannerOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+ArtistIndexBannerOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexBannerOptionsModal;
diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js
new file mode 100644
index 000000000..6bfcad0bb
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.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 bannerSizeOptions = [
+ { key: 'small', value: 'Small' },
+ { key: 'medium', value: 'Medium' },
+ { key: 'large', value: 'Large' }
+];
+
+class ArtistIndexBannerOptionsModalContent 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
+
+ onChangeBannerOption = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onChangeBannerOption({ [name]: value });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+ArtistIndexBannerOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ onChangeBannerOption: PropTypes.func.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexBannerOptionsModalContent;
diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js
new file mode 100644
index 000000000..884edd05d
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistBannerOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex,
+ (artistIndex) => {
+ return artistIndex.bannerOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangeBannerOption(payload) {
+ dispatch(setArtistBannerOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexBannerOptionsModalContent);
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js
new file mode 100644
index 000000000..818e83311
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.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 ArtistIndexFilterModalConnector from 'Artist/Index/ArtistIndexFilterModalConnector';
+
+function ArtistIndexFilterMenu(props) {
+ const {
+ selectedFilterKey,
+ filters,
+ customFilters,
+ isDisabled,
+ onFilterSelect
+ } = props;
+
+ return (
+
+ );
+}
+
+ArtistIndexFilterMenu.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
+};
+
+ArtistIndexFilterMenu.defaultProps = {
+ showCustomFilters: false
+};
+
+export default ArtistIndexFilterMenu;
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js
new file mode 100644
index 000000000..fc5854648
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js
@@ -0,0 +1,150 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, sortDirections } from 'Helpers/Props';
+import SortMenu from 'Components/Menu/SortMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import SortMenuItem from 'Components/Menu/SortMenuItem';
+
+function ArtistIndexSortMenu(props) {
+ const {
+ sortKey,
+ sortDirection,
+ isDisabled,
+ onSortSelect
+ } = props;
+
+ return (
+
+
+
+ Monitored/Status
+
+
+
+ Name
+
+
+
+ Type
+
+
+
+ Quality Profile
+
+
+
+ Metadata Profile
+
+
+
+ Next Album
+
+
+
+ Last Album
+
+
+
+ Added
+
+
+
+ Albums
+
+
+
+ Tracks
+
+
+
+ Track Count
+
+
+
+ Path
+
+
+
+ Size on Disk
+
+
+
+ );
+}
+
+ArtistIndexSortMenu.propTypes = {
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ isDisabled: PropTypes.bool.isRequired,
+ onSortSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexSortMenu;
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js
new file mode 100644
index 000000000..46ca03b9f
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import ViewMenu from 'Components/Menu/ViewMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import ViewMenuItem from 'Components/Menu/ViewMenuItem';
+
+function ArtistIndexViewMenu(props) {
+ const {
+ view,
+ isDisabled,
+ onViewSelect
+ } = props;
+
+ return (
+
+
+
+ Table
+
+
+
+ Posters
+
+
+
+ Banners
+
+
+
+ Overview
+
+
+
+ );
+}
+
+ArtistIndexViewMenu.propTypes = {
+ view: PropTypes.string.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ onViewSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexViewMenu;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css
new file mode 100644
index 000000000..054319ebc
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.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/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js
new file mode 100644
index 000000000..6be34d622
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js
@@ -0,0 +1,284 @@
+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 ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
+import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo';
+import styles from './ArtistIndexOverview.css';
+
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+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 ArtistIndexOverview extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ artistName,
+ overview,
+ monitored,
+ status,
+ foreignArtistId,
+ nextAiring,
+ statistics,
+ images,
+ posterWidth,
+ posterHeight,
+ qualityProfile,
+ overviewOptions,
+ showSearchAction,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ rowHeight,
+ isSmallScreen,
+ isRefreshingArtist,
+ isSearchingArtist,
+ onRefreshArtistPress,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ albumCount,
+ sizeOnDisk,
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ } = statistics;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const link = `/artist/${foreignArtistId}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ const contentHeight = getContentHeight(rowHeight, isSmallScreen);
+ const overviewHeight = contentHeight - titleRowHeight;
+
+ return (
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {artistName}
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexOverview.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ overview: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ rowHeight: PropTypes.number.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ overviewOptions: PropTypes.object.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ isSearchingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+ArtistIndexOverview.defaultProps = {
+ statistics: {
+ albumCount: 0,
+ trackCount: 0,
+ trackFileCount: 0,
+ totalTrackCount: 0
+ }
+};
+
+export default ArtistIndexOverview;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css
new file mode 100644
index 000000000..5dc53762f
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.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/Artist/Index/Overview/ArtistIndexOverviewInfo.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js
new file mode 100644
index 000000000..f7839cab5
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js
@@ -0,0 +1,250 @@
+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 ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow';
+import styles from './ArtistIndexOverviewInfo.css';
+
+const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight);
+
+const rows = [
+ {
+ name: 'monitored',
+ showProp: 'showMonitored',
+ valueProp: 'monitored'
+
+ },
+ {
+ name: 'qualityProfileId',
+ showProp: 'showQualityProfile',
+ valueProp: 'qualityProfileId'
+ },
+ {
+ name: 'lastAlbum',
+ showProp: 'showLastAlbum',
+ valueProp: 'lastAlbum'
+ },
+ {
+ name: 'added',
+ showProp: 'showAdded',
+ valueProp: 'added'
+ },
+ {
+ name: 'albumCount',
+ showProp: 'showAlbumCount',
+ valueProp: 'albumCount'
+ },
+ {
+ 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 === 'qualityProfileId') {
+ return {
+ title: 'Quality Profile',
+ iconName: icons.PROFILE,
+ label: props.qualityProfile.name
+ };
+ }
+
+ if (name === 'lastAlbum') {
+ const {
+ lastAlbum,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ return {
+ title: `Last Album: ${lastAlbum.title}`,
+ iconName: icons.CALENDAR,
+ label: getRelativeDate(
+ lastAlbum.releaseDate,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ };
+ }
+
+ if (name === 'added') {
+ const {
+ added,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat
+ } = props;
+
+ return {
+ title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`,
+ iconName: icons.ADD,
+ label: getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ };
+ }
+
+ if (name === 'albumCount') {
+ const { albumCount } = props;
+ let albums = '1 album';
+
+ if (albumCount === 0) {
+ albums = 'No albums';
+ } else if (albumCount > 1) {
+ albums = `${albumCount} albums`;
+ }
+
+ return {
+ title: 'Album Count',
+ iconName: icons.CIRCLE,
+ label: albums
+ };
+ }
+
+ 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 ArtistIndexOverviewInfo(props) {
+ const {
+ height,
+ nextAiring,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat
+ } = props;
+
+ let shownRows = 1;
+
+ const maxRows = Math.floor(height / (infoRowHeight + 4));
+
+ return (
+
+ {
+ !!nextAiring &&
+
+ }
+
+ {
+ rows.map((row) => {
+ if (!isVisible(row, props)) {
+ return null;
+ }
+
+ if (shownRows >= maxRows) {
+ return null;
+ }
+
+ shownRows++;
+
+ const infoRowProps = getInfoRowProps(row, props);
+
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+ArtistIndexOverviewInfo.propTypes = {
+ height: PropTypes.number.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showAlbumCount: PropTypes.bool.isRequired,
+ showPath: PropTypes.bool.isRequired,
+ showSizeOnDisk: PropTypes.bool.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ nextAiring: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ lastAlbum: PropTypes.object,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default ArtistIndexOverviewInfo;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.css
new file mode 100644
index 000000000..1fcd432a3
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.css
@@ -0,0 +1,10 @@
+.infoRow {
+ flex: 0 0 $artistIndexOverviewInfoRowHeight;
+ margin: 2px 0;
+}
+
+.icon {
+ margin-right: 5px;
+ width: 25px !important;
+ text-align: center;
+}
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js
new file mode 100644
index 000000000..b04029b88
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import styles from './ArtistIndexOverviewInfoRow.css';
+
+function ArtistIndexOverviewInfoRow(props) {
+ const {
+ title,
+ iconName,
+ label
+ } = props;
+
+ return (
+
+
+
+ {label}
+
+ );
+}
+
+ArtistIndexOverviewInfoRow.propTypes = {
+ title: PropTypes.string,
+ iconName: PropTypes.object.isRequired,
+ label: PropTypes.string.isRequired
+};
+
+export default ArtistIndexOverviewInfoRow;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js
new file mode 100644
index 000000000..8b23cdf95
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js
@@ -0,0 +1,289 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { Grid, WindowScroller } from 'react-virtualized';
+import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import Measure from 'Components/Measure';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexOverview from './ArtistIndexOverview';
+import styles from './ArtistIndexOverviews.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+function calculatePosterWidth(posterSize, isSmallScreen) {
+ const maxiumPosterWidth = isSmallScreen ? 192 : 202;
+
+ 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 posterWidth;
+}
+
+class ArtistIndexOverviews extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnCount: 1,
+ posterWidth: 238,
+ 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 artist = items[rowIndex];
+
+ if (!artist) {
+ 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 (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+ArtistIndexOverviews.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 ArtistIndexOverviews;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js
new file mode 100644
index 000000000..595a471b1
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import ArtistIndexOverviews from './ArtistIndexOverviews';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex.overviewOptions,
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (overviewOptions, uiSettings, dimensions) => {
+ return {
+ overviewOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ArtistIndexOverviews);
diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js
new file mode 100644
index 000000000..9ca575185
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistIndexOverviewOptionsModalContentConnector from './ArtistIndexOverviewOptionsModalContentConnector';
+
+function ArtistIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+ArtistIndexOverviewOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexOverviewOptionsModal;
diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js
new file mode 100644
index 000000000..2fe569965
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js
@@ -0,0 +1,287 @@
+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 ArtistIndexOverviewOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showMonitored: props.showMonitored,
+ showQualityProfile: props.showQualityProfile,
+ showLastAlbum: props.showLastAlbum,
+ showAdded: props.showAdded,
+ showAlbumCount: props.showAlbumCount,
+ showPath: props.showPath,
+ showSizeOnDisk: props.showSizeOnDisk,
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showMonitored,
+ showQualityProfile,
+ showLastAlbum,
+ showAdded,
+ showAlbumCount,
+ 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 (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (showLastAlbum !== prevProps.showLastAlbum) {
+ state.showLastAlbum = showLastAlbum;
+ }
+
+ if (showAdded !== prevProps.showAdded) {
+ state.showAdded = showAdded;
+ }
+
+ if (showAlbumCount !== prevProps.showAlbumCount) {
+ state.showAlbumCount = showAlbumCount;
+ }
+
+ 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,
+ showQualityProfile,
+ showLastAlbum,
+ showAdded,
+ showAlbumCount,
+ showPath,
+ showSizeOnDisk,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Overview Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+ArtistIndexOverviewOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showLastAlbum: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showAlbumCount: 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 ArtistIndexOverviewOptionsModalContent;
diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js
new file mode 100644
index 000000000..70c30dba6
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex,
+ (artistIndex) => {
+ return artistIndex.overviewOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangeOverviewOption(payload) {
+ dispatch(setArtistOverviewOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexOverviewOptionsModalContent);
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css
new file mode 100644
index 000000000..cd378e34c
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css
@@ -0,0 +1,103 @@
+$hoverScale: 1.05;
+
+.container {
+ padding: 10px;
+}
+
+.content {
+ transition: all 200ms ease-in;
+
+ &:hover {
+ z-index: 2;
+ box-shadow: 0 0 12px $black;
+ transition: all 200ms ease-in;
+
+ .controls {
+ opacity: 0.9;
+ transition: opacity 200ms linear 150ms;
+ }
+ }
+}
+
+.posterContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from '~Components/Link/Link.css';
+
+ position: relative;
+ display: block;
+ height: 70px;
+ background-color: $defaultColor;
+}
+
+.overlayTitle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 5px;
+ width: 100%;
+ height: 100%;
+ color: $offWhite;
+ text-align: center;
+ font-size: 20px;
+}
+
+.nextAiring {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.title {
+ @add-mixin truncate;
+
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.controls {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ z-index: 3;
+ border-radius: 4px;
+ background-color: #216044;
+ color: $white;
+ font-size: $smallFontSize;
+ opacity: 0;
+ transition: opacity 0;
+}
+
+.action {
+ composes: button from '~Components/Link/IconButton.css';
+
+ &:hover {
+ color: #ccc;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .container {
+ padding: 5px;
+ }
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js
new file mode 100644
index 000000000..101b49f7b
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js
@@ -0,0 +1,295 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
+import ArtistIndexPosterInfo from './ArtistIndexPosterInfo';
+import styles from './ArtistIndexPoster.css';
+
+class ArtistIndexPoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPosterError: false,
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onPosterLoad = () => {
+ if (this.state.hasPosterError) {
+ this.setState({ hasPosterError: false });
+ }
+ }
+
+ onPosterLoadError = () => {
+ if (!this.state.hasPosterError) {
+ this.setState({ hasPosterError: true });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ artistName,
+ monitored,
+ foreignArtistId,
+ status,
+ nextAiring,
+ statistics,
+ images,
+ posterWidth,
+ posterHeight,
+ detailedProgressBar,
+ showTitle,
+ showMonitored,
+ showQualityProfile,
+ qualityProfile,
+ showSearchAction,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isRefreshingArtist,
+ isSearchingArtist,
+ onRefreshArtistPress,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ albumCount,
+ sizeOnDisk,
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ } = statistics;
+
+ const {
+ hasPosterError,
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const link = `/artist/${foreignArtistId}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ return (
+
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+ {
+ hasPosterError &&
+
+ {artistName}
+
+ }
+
+
+
+
+
+
+ {
+ showTitle &&
+
+ {artistName}
+
+ }
+
+ {
+ showMonitored &&
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+ }
+
+ {
+ showQualityProfile &&
+
+ {qualityProfile.name}
+
+ }
+ {
+ nextAiring &&
+
+ {
+ getRelativeDate(
+ nextAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexPoster.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showMonitored: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ isSearchingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+ArtistIndexPoster.defaultProps = {
+ statistics: {
+ albumCount: 0,
+ trackCount: 0,
+ trackFileCount: 0,
+ totalTrackCount: 0
+ }
+};
+
+export default ArtistIndexPoster;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css
new file mode 100644
index 000000000..aab27d827
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css
@@ -0,0 +1,5 @@
+.info {
+ background-color: #fafbfc;
+ text-align: center;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js
new file mode 100644
index 000000000..591961605
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import styles from './ArtistIndexPosterInfo.css';
+
+function ArtistIndexPosterInfo(props) {
+ const {
+ qualityProfile,
+ showQualityProfile,
+ previousAiring,
+ added,
+ albumCount,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (sortKey === 'qualityProfileId' && !showQualityProfile) {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (sortKey === 'previousAiring' && previousAiring) {
+ return (
+
+ {
+ getRelativeDate(
+ previousAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ );
+ }
+
+ if (sortKey === 'added' && added) {
+ const addedDate = getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: false
+ }
+ );
+
+ return (
+
+ {`Added ${addedDate}`}
+
+ );
+ }
+
+ if (sortKey === 'albumCount') {
+ let albums = '1 album';
+
+ if (albumCount === 0) {
+ albums = 'No albums';
+ } else if (albumCount > 1) {
+ albums = `${albumCount} albums`;
+ }
+
+ return (
+
+ {albums}
+
+ );
+ }
+
+ if (sortKey === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (sortKey === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ return null;
+}
+
+ArtistIndexPosterInfo.propTypes = {
+ qualityProfile: PropTypes.object.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default ArtistIndexPosterInfo;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js
new file mode 100644
index 000000000..3650db93e
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js
@@ -0,0 +1,326 @@
+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 ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexPoster from './ArtistIndexPoster';
+import styles from './ArtistIndexPosters.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+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 'seasons':
+ case 'previousAiring':
+ case 'added':
+ case 'path':
+ case 'sizeOnDisk':
+ heights.push(19);
+ break;
+ case 'qualityProfileId':
+ if (!showQualityProfile) {
+ heights.push(19);
+ }
+ break;
+ default:
+ // No need to add a height of 0
+ }
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return Math.ceil(posterWidth);
+}
+
+class ArtistIndexPosters extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnWidth: 182,
+ columnCount: 1,
+ posterWidth: 238,
+ 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 artist = items[rowIndex * columnCount + columnIndex];
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.calculateGrid(width, this.props.isSmallScreen);
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ scrollTop,
+ isSmallScreen,
+ onScroll
+ } = this.props;
+
+ const {
+ width,
+ columnWidth,
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const rowCount = Math.ceil(items.length / columnCount);
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+ArtistIndexPosters.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ 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 ArtistIndexPosters;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js
new file mode 100644
index 000000000..04c187e4e
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import ArtistIndexPosters from './ArtistIndexPosters';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex.posterOptions,
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (posterOptions, uiSettings, dimensions) => {
+ return {
+ posterOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ArtistIndexPosters);
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js
new file mode 100644
index 000000000..e1b0a257a
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector';
+
+function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+ArtistIndexPosterOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexPosterOptionsModal;
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js
new file mode 100644
index 000000000..6918436a6
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.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 ArtistIndexPosterOptionsModalContent 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
+
+
+
+ );
+ }
+}
+
+ArtistIndexPosterOptionsModalContent.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 ArtistIndexPosterOptionsModalContent;
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js
new file mode 100644
index 000000000..72af268ad
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistPosterOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex,
+ (artistIndex) => {
+ return artistIndex.posterOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangePosterOption(payload) {
+ dispatch(setArtistPosterOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent);
diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css
new file mode 100644
index 000000000..b98bb33d5
--- /dev/null
+++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css
@@ -0,0 +1,14 @@
+.progress {
+ composes: container from '~Components/ProgressBar.css';
+
+ border-radius: 0;
+ background-color: #5b5b5b;
+ color: $white;
+ transition: width 200ms ease;
+}
+
+.progressBar {
+ composes: progressBar from '~Components/ProgressBar.css';
+
+ transition: width 200ms ease;
+}
diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js
new file mode 100644
index 000000000..6be32a46d
--- /dev/null
+++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getProgressBarKind from 'Utilities/Artist/getProgressBarKind';
+import { sizes } from 'Helpers/Props';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './ArtistIndexProgressBar.css';
+
+function ArtistIndexProgressBar(props) {
+ const {
+ monitored,
+ status,
+ trackCount,
+ trackFileCount,
+ totalTrackCount,
+ posterWidth,
+ detailedProgressBar
+ } = props;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+ const text = `${trackFileCount} / ${trackCount}`;
+
+ return (
+
+ );
+}
+
+ArtistIndexProgressBar.propTypes = {
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ trackCount: PropTypes.number.isRequired,
+ trackFileCount: PropTypes.number.isRequired,
+ totalTrackCount: PropTypes.number.isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired
+};
+
+export default ArtistIndexProgressBar;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js
new file mode 100644
index 000000000..3f37cd56a
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+
+class ArtistIndexActionsCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ isRefreshingArtist,
+ onRefreshArtistPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexActionsCell.propTypes = {
+ id: PropTypes.number.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired
+};
+
+export default ArtistIndexActionsCell;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css
new file mode 100644
index 000000000..6da0be920
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css
@@ -0,0 +1,96 @@
+.status {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortName {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 4 0 110px;
+}
+
+.banner {
+ flex: 0 0 379px;
+}
+
+.bannerGrow {
+ flex-grow: 1;
+}
+
+.artistType {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 100px;
+}
+
+.qualityProfileId,
+.metadataProfileId {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 125px;
+}
+
+.nextAlbum,
+.lastAlbum,
+.added,
+.genres {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 180px;
+}
+
+.albumCount {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 100px;
+}
+
+.trackProgress,
+.latestAlbum {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 150px;
+}
+
+.trackCount {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 130px;
+}
+
+.path {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 120px;
+}
+
+.ratings {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 80px;
+}
+
+.tags {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 60px;
+}
+
+.useSceneNumbering {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 145px;
+}
+
+.actions {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 90px;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js
new file mode 100644
index 000000000..aed47bafa
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import hasGrowableColumns from './hasGrowableColumns';
+import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector';
+import styles from './ArtistIndexHeader.css';
+
+function ArtistIndexHeader(props) {
+ const {
+ showBanners,
+ columns,
+ onTableOptionChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ label,
+ isSortable,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {label}
+
+ );
+ })
+ }
+
+ );
+}
+
+ArtistIndexHeader.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ showBanners: PropTypes.bool.isRequired
+};
+
+export default ArtistIndexHeader;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js
new file mode 100644
index 000000000..37ddd9ef3
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { setArtistTableOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexHeader from './ArtistIndexHeader';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setArtistTableOption(payload));
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader);
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css
new file mode 100644
index 000000000..29c89c696
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css
@@ -0,0 +1,141 @@
+.cell {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: flex;
+ align-items: center;
+}
+
+.status {
+ composes: cell;
+
+ flex: 0 0 60px;
+}
+
+.sortName {
+ composes: cell;
+
+ flex: 4 0 110px;
+}
+
+.artistType {
+ composes: cell;
+
+ flex: 0 0 100px;
+}
+
+.banner {
+ flex: 0 0 379px;
+}
+
+.bannerGrow {
+ flex-grow: 1;
+}
+
+.link {
+ composes: link from '~Components/Link/Link.css';
+
+ position: relative;
+ display: block;
+ height: 70px;
+ background-color: $defaultColor;
+}
+
+.bannerImage {
+ width: 379px;
+ height: 70px;
+}
+
+.overlayTitle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 5px;
+ width: 100%;
+ height: 100%;
+ color: $offWhite;
+ text-align: center;
+ font-size: 20px;
+}
+
+.qualityProfileId,
+.metadataProfileId {
+ composes: cell;
+
+ flex: 1 0 125px;
+}
+
+.nextAlbum,
+.lastAlbum,
+.added,
+.genres {
+ composes: cell;
+
+ flex: 0 0 180px;
+}
+
+.albumCount {
+ composes: cell;
+
+ flex: 0 0 100px;
+}
+
+.trackProgress {
+ composes: cell;
+
+ display: flex;
+ justify-content: center;
+ flex: 0 0 150px;
+ flex-direction: column;
+}
+
+.trackCount {
+ composes: cell;
+
+ flex: 0 0 130px;
+}
+
+.path {
+ composes: cell;
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: cell;
+
+ flex: 0 0 120px;
+}
+
+.ratings {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 80px;
+}
+
+.tags {
+ composes: cell;
+
+ flex: 1 0 60px;
+}
+
+.useSceneNumbering {
+ composes: cell;
+
+ flex: 0 0 145px;
+}
+
+.actions {
+ composes: cell;
+
+ flex: 0 1 90px;
+ min-width: 60px;
+}
+
+.checkInput {
+ composes: input from '~Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js
new file mode 100644
index 000000000..6b597509f
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.js
@@ -0,0 +1,484 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import getProgressBarKind from 'Utilities/Artist/getProgressBarKind';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import HeartRating from 'Components/HeartRating';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ProgressBar from 'Components/ProgressBar';
+import TagListConnector from 'Components/TagListConnector';
+// import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableRow from 'Components/Table/VirtualTableRow';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import AlbumTitleLink from 'Album/AlbumTitleLink';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistBanner from 'Artist/ArtistBanner';
+import hasGrowableColumns from './hasGrowableColumns';
+import ArtistStatusCell from './ArtistStatusCell';
+import styles from './ArtistIndexRow.css';
+
+class ArtistIndexRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasBannerError: false,
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onUseSceneNumberingChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ onBannerLoad = () => {
+ if (this.state.hasBannerError) {
+ this.setState({ hasBannerError: false });
+ }
+ }
+
+ onBannerLoadError = () => {
+ if (!this.state.hasBannerError) {
+ this.setState({ hasBannerError: true });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ monitored,
+ status,
+ artistName,
+ foreignArtistId,
+ artistType,
+ qualityProfile,
+ metadataProfile,
+ nextAlbum,
+ lastAlbum,
+ added,
+ statistics,
+ genres,
+ ratings,
+ path,
+ tags,
+ images,
+ showBanners,
+ showSearchAction,
+ columns,
+ isRefreshingArtist,
+ isSearchingArtist,
+ onRefreshArtistPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ albumCount,
+ trackCount,
+ trackFileCount,
+ totalTrackCount,
+ sizeOnDisk
+ } = statistics;
+
+ const {
+ hasBannerError,
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'status') {
+ return (
+
+ );
+ }
+
+ if (name === 'sortName') {
+ return (
+
+ {
+ showBanners ?
+
+
+
+ {
+ hasBannerError &&
+
+ {artistName}
+
+ }
+ :
+
+
+ }
+
+ );
+ }
+
+ if (name === 'artistType') {
+ return (
+
+ {artistType}
+
+ );
+ }
+
+ if (name === 'qualityProfileId') {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (name === 'metadataProfileId') {
+ return (
+
+ {metadataProfile.name}
+
+ );
+ }
+
+ if (name === 'nextAlbum') {
+ if (nextAlbum) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ None
+
+ );
+ }
+
+ if (name === 'lastAlbum') {
+ if (lastAlbum) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ None
+
+ );
+ }
+
+ if (name === 'added') {
+ return (
+
+ );
+ }
+
+ if (name === 'albumCount') {
+ return (
+
+ {albumCount}
+
+ );
+ }
+
+ if (name === 'trackProgress') {
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'trackCount') {
+ return (
+
+ {totalTrackCount}
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (name === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ if (name === 'genres') {
+ const joinedGenres = genres.join(', ');
+
+ return (
+
+
+ {joinedGenres}
+
+
+ );
+ }
+
+ if (name === 'ratings') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'tags') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ artistType: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ metadataProfile: PropTypes.object.isRequired,
+ nextAlbum: PropTypes.object,
+ lastAlbum: PropTypes.object,
+ added: PropTypes.string,
+ statistics: PropTypes.object.isRequired,
+ latestAlbum: PropTypes.object,
+ path: PropTypes.string.isRequired,
+ genres: PropTypes.arrayOf(PropTypes.string).isRequired,
+ ratings: PropTypes.object.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ showBanners: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ isSearchingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+ArtistIndexRow.defaultProps = {
+ statistics: {
+ albumCount: 0,
+ trackCount: 0,
+ trackFileCount: 0,
+ totalTrackCount: 0
+ },
+ genres: [],
+ tags: []
+};
+
+export default ArtistIndexRow;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css b/frontend/src/Artist/Index/Table/ArtistIndexTable.css
new file mode 100644
index 000000000..23ab127b5
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css
@@ -0,0 +1,5 @@
+.tableContainer {
+ composes: tableContainer from '~Components/Table/VirtualTable.css';
+
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js
new file mode 100644
index 000000000..fcece7a2c
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.js
@@ -0,0 +1,132 @@
+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 ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector';
+import ArtistIndexRow from './ArtistIndexRow';
+import styles from './ArtistIndexTable.css';
+
+class ArtistIndexTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ scrollIndex: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const jumpToCharacter = this.props.jumpToCharacter;
+
+ if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
+ const items = this.props.items;
+
+ const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter);
+
+ if (scrollIndex != null) {
+ this.setState({ scrollIndex });
+ }
+ } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) {
+ this.setState({ scrollIndex: null });
+ }
+ }
+
+ //
+ // Control
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ columns,
+ showBanners
+ } = this.props;
+
+ const artist = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ columns,
+ filters,
+ sortKey,
+ sortDirection,
+ showBanners,
+ isSmallScreen,
+ scrollTop,
+ contentBody,
+ onSortPress,
+ onRender,
+ onScroll
+ } = this.props;
+
+ return (
+
+ }
+ columns={columns}
+ filters={filters}
+ sortKey={sortKey}
+ sortDirection={sortDirection}
+ onRender={onRender}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+ArtistIndexTable.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ showBanners: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ jumpToCharacter: PropTypes.string,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndexTable;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js
new file mode 100644
index 000000000..3a97425cc
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js
@@ -0,0 +1,29 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistSort } from 'Store/Actions/artistIndexActions';
+import ArtistIndexTable from './ArtistIndexTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ (state) => state.artistIndex.tableOptions,
+ (state) => state.artistIndex.columns,
+ (dimensions, tableOptions, columns) => {
+ return {
+ isSmallScreen: dimensions.isSmallScreen,
+ showBanners: tableOptions.showBanners,
+ columns
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSortPress(sortKey) {
+ dispatch(setArtistSort({ sortKey }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexTable);
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js
new file mode 100644
index 000000000..110a024e4
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+class ArtistIndexTableOptions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ showBanners: props.showBanners,
+ showSearchAction: props.showSearchAction
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ showBanners,
+ showSearchAction
+ } = this.props;
+
+ if (
+ showBanners !== prevProps.showBanners ||
+ showSearchAction !== prevProps.showSearchAction
+ ) {
+ this.setState({
+ showBanners,
+ showSearchAction
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onTableOptionChange = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onTableOptionChange({
+ tableOptions: {
+ ...this.state,
+ [name]: value
+ }
+ });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ showBanners,
+ showSearchAction
+ } = this.state;
+
+ return (
+
+
+ Show Banners
+
+
+
+
+
+ Show Search
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexTableOptions.propTypes = {
+ showBanners: PropTypes.bool.isRequired,
+ showSearchAction: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default ArtistIndexTableOptions;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js
new file mode 100644
index 000000000..0a1607cf2
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import ArtistIndexTableOptions from './ArtistIndexTableOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex.tableOptions,
+ (tableOptions) => {
+ return tableOptions;
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ArtistIndexTableOptions);
diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.css b/frontend/src/Artist/Index/Table/ArtistStatusCell.css
new file mode 100644
index 000000000..fbcd5eee9
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.css
@@ -0,0 +1,9 @@
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 60px;
+}
+
+.statusIcon {
+ width: 20px !important;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.js b/frontend/src/Artist/Index/Table/ArtistStatusCell.js
new file mode 100644
index 000000000..26fde0e12
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './ArtistStatusCell.css';
+
+function ArtistStatusCell(props) {
+ const {
+ className,
+ artistType,
+ monitored,
+ status,
+ component: Component,
+ ...otherProps
+ } = props;
+
+ const endedString = artistType === 'Person' ? 'Deceased' : 'Ended';
+
+ return (
+
+
+
+
+
+ );
+}
+
+ArtistStatusCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ artistType: PropTypes.string,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ component: PropTypes.elementType
+};
+
+ArtistStatusCell.defaultProps = {
+ className: styles.status,
+ component: VirtualTableRowCell
+};
+
+export default ArtistStatusCell;
diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.js b/frontend/src/Artist/Index/Table/hasGrowableColumns.js
new file mode 100644
index 000000000..994436d9f
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/hasGrowableColumns.js
@@ -0,0 +1,16 @@
+const growableColumns = [
+ 'qualityProfileId',
+ 'path',
+ 'tags'
+];
+
+export default function hasGrowableColumns(columns) {
+ return columns.some((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ return growableColumns.includes(name) && isVisible;
+ });
+}
diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.css b/frontend/src/Artist/MoveArtist/MoveArtistModal.css
new file mode 100644
index 000000000..c1e247a50
--- /dev/null
+++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.css
@@ -0,0 +1,5 @@
+.doNotMoveButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.js b/frontend/src/Artist/MoveArtist/MoveArtistModal.js
new file mode 100644
index 000000000..3f78187ff
--- /dev/null
+++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.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 './MoveArtistModal.css';
+
+function MoveArtistModal(props) {
+ const {
+ originalPath,
+ destinationPath,
+ destinationRootFolder,
+ isOpen,
+ onSavePress,
+ onMoveArtistPress
+ } = 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 artist folders to '${destinationRootFolder}'?` :
+ `Would you like to move the artist files from '${originalPath}' to '${destinationPath}'?`
+ }
+
+
+
+
+ No, I'll Move the Files Myself
+
+
+
+ Yes, Move the Files
+
+
+
+
+ );
+}
+
+MoveArtistModal.propTypes = {
+ originalPath: PropTypes.string,
+ destinationPath: PropTypes.string,
+ destinationRootFolder: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onMoveArtistPress: PropTypes.func.isRequired
+};
+
+export default MoveArtistModal;
diff --git a/frontend/src/Artist/NoArtist.css b/frontend/src/Artist/NoArtist.css
new file mode 100644
index 000000000..38a01f391
--- /dev/null
+++ b/frontend/src/Artist/NoArtist.css
@@ -0,0 +1,11 @@
+.message {
+ margin-top: 10px;
+ margin-bottom: 30px;
+ text-align: center;
+ font-size: 20px;
+}
+
+.buttonContainer {
+ margin-top: 20px;
+ text-align: center;
+}
diff --git a/frontend/src/Artist/NoArtist.js b/frontend/src/Artist/NoArtist.js
new file mode 100644
index 000000000..b869a8d58
--- /dev/null
+++ b/frontend/src/Artist/NoArtist.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 './NoArtist.css';
+
+function NoArtist(props) {
+ const { totalItems } = props;
+
+ if (totalItems > 0) {
+ return (
+
+
+ All artists are hidden due to the applied filter.
+
+
+ );
+ }
+
+ return (
+
+
+ No artist found, to get started you'll want to add a new artist or import some existing ones.
+
+
+
+
+ Import Existing Artist(s)
+
+
+
+
+
+ Add New Artist
+
+
+
+ );
+}
+
+NoArtist.propTypes = {
+ totalItems: PropTypes.number.isRequired
+};
+
+export default NoArtist;
diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js
new file mode 100644
index 000000000..0da3661a8
--- /dev/null
+++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistInteractiveSearchModalContent from './ArtistInteractiveSearchModalContent';
+
+function ArtistInteractiveSearchModal(props) {
+ const {
+ isOpen,
+ artistId,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+ArtistInteractiveSearchModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ artistId: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistInteractiveSearchModal;
diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js
new file mode 100644
index 000000000..fe3170570
--- /dev/null
+++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
+import ArtistInteractiveSearchModal from './ArtistInteractiveSearchModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ dispatch(cancelFetchReleases());
+ dispatch(clearReleases());
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(ArtistInteractiveSearchModal);
diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js
new file mode 100644
index 000000000..9b7f4c6ed
--- /dev/null
+++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js
@@ -0,0 +1,45 @@
+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 ArtistInteractiveSearchModalContent(props) {
+ const {
+ artistId,
+ onModalClose
+ } = props;
+
+ return (
+
+
+ Interactive Search
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+ArtistInteractiveSearchModalContent.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistInteractiveSearchModalContent;
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..33d02cd79
--- /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.releaseDate);
+ const showDate = index === 0 ||
+ !moment(items[index - 1].releaseDate).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..876c9fc75
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css
@@ -0,0 +1,113 @@
+.event {
+ display: flex;
+ overflow-x: hidden;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ font-size: $defaultFontSize;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.eventWrapper {
+ display: flex;
+ flex: 1 0 1px;
+ overflow-x: hidden;
+ padding-left: 6px;
+ border-left-width: 4px;
+ border-left-style: solid;
+}
+
+.date {
+ flex: 0 0 250px;
+ font-weight: bold;
+}
+
+.time {
+ flex: 0 0 120px;
+ margin-right: 10px;
+ border: none !important;
+}
+
+.artistName,
+.albumTitle {
+ @add-mixin truncate;
+
+ flex: 0 1 300px;
+ margin-right: 10px;
+}
+
+.albumTitle {
+ flex: 1 1 1px;
+}
+
+.seasonEpisodeNumber {
+ flex: 0 0 100px;
+}
+
+.albumSeparator {
+ display: none;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from '~Calendar/Events/CalendarEvent.css';
+}
+
+.partial {
+ composes: partial from '~Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from '~Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from '~Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from '~Calendar/Events/CalendarEvent.css';
+}
+
+.unreleased {
+ composes: unreleased 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,
+ .artistName {
+ flex: 0 0 100%;
+ }
+
+ .seasonEpisodeNumber {
+ flex: 0 0 auto;
+ }
+
+ .albumSeparator {
+ 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..44ad53063
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.js
@@ -0,0 +1,145 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import formatTime from 'Utilities/Date/formatTime';
+import { icons } 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 styles from './AgendaEvent.css';
+
+class AgendaEvent extends Component {
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ artist,
+ title,
+ foreignAlbumId,
+ releaseDate,
+ monitored,
+ statistics,
+ grabbed,
+ queueItem,
+ showDate,
+ timeFormat,
+ longDateFormat,
+ colorImpairedMode
+ } = this.props;
+
+ const startTime = moment(releaseDate);
+ // const endTime = startTime.add(artist.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = artist.monitored && monitored;
+ const statusStyle = getStatusStyle(id, downloading, startTime, isMonitored, statistics.percentOfTracks);
+
+ return (
+
+
+
+ {
+ showDate &&
+ startTime.format(longDateFormat)
+ }
+
+
+
+
+
+ {formatTime(releaseDate, timeFormat)}
+
+
+
+
+ {artist.artistName}
+
+
+
+
-
+
+
+
+ {title}
+
+
+
+ {
+ !!queueItem &&
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+
+ );
+ }
+}
+
+AgendaEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ artist: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ statistics: PropTypes.object.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ showDate: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+AgendaEvent.defaultProps = {
+ statistics: {
+ percentOfTracks: 0
+ }
+};
+
+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..b0ab00f1b
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import AgendaEvent from './AgendaEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (artist, queueItem, uiSettings) => {
+ return {
+ artist,
+ queueItem,
+ 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..a97589c59
--- /dev/null
+++ b/frontend/src/Calendar/CalendarConnector.js
@@ -0,0 +1,174 @@
+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 { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import Calendar from './Calendar';
+
+const UPDATE_DELAY = 3600000; // 1 hour
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...calendarActions,
+ fetchTrackFiles,
+ clearTrackFiles,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class CalendarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchCalendar,
+ gotoCalendarToday
+ } = this.props;
+
+ registerPagePopulator(this.repopulate);
+
+ if (useCurrentPage) {
+ fetchCalendar();
+ } else {
+ gotoCalendarToday();
+ }
+
+ this.scheduleUpdate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ time
+ } = this.props;
+
+ if (hasDifferentItems(prevProps.items, items)) {
+ const albumIds = selectUniqueIds(items, 'id');
+ // const trackFileIds = selectUniqueIds(items, 'trackFileId');
+
+ if (items.length) {
+ this.props.fetchQueueDetails({ albumIds });
+ }
+
+ // if (trackFileIds.length) {
+ // this.props.fetchTrackFiles({ trackFileIds });
+ // }
+ }
+
+ if (prevProps.time !== time) {
+ this.scheduleUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCalendar();
+ this.props.clearQueueDetails();
+ this.props.clearTrackFiles();
+ 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 = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ time: PropTypes.string,
+ view: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ setCalendarView: PropTypes.func.isRequired,
+ gotoCalendarToday: PropTypes.func.isRequired,
+ gotoCalendarPreviousRange: PropTypes.func.isRequired,
+ gotoCalendarNextRange: PropTypes.func.isRequired,
+ clearCalendar: PropTypes.func.isRequired,
+ fetchCalendar: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: 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..b6839c467
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.css
@@ -0,0 +1,20 @@
+.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%;
+}
+
+.errorMessage {
+ margin-top: 20px;
+ text-align: center;
+ font-size: 20px;
+}
diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js
new file mode 100644
index 000000000..9dfe3229e
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.js
@@ -0,0 +1,193 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+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 NoArtist from 'Artist/NoArtist';
+import CalendarLinkModal from './iCal/CalendarLinkModal';
+import CalendarOptionsModal from './Options/CalendarOptionsModal';
+import LegendConnector from './Legend/LegendConnector';
+import CalendarConnector from './CalendarConnector';
+import styles from './CalendarPage.css';
+
+const MINIMUM_DAY_WIDTH = 120;
+
+class CalendarPage extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isCalendarLinkModalOpen: false,
+ isOptionsModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
+
+ this.props.onDaysCountChange(days);
+ }
+
+ onGetCalendarLinkPress = () => {
+ this.setState({ isCalendarLinkModalOpen: true });
+ }
+
+ onGetCalendarLinkModalClose = () => {
+ this.setState({ isCalendarLinkModalOpen: false });
+ }
+
+ onOptionsPress = () => {
+ this.setState({ isOptionsModalOpen: true });
+ }
+
+ onOptionsModalClose = () => {
+ this.setState({ isOptionsModalOpen: false });
+ }
+
+ onSearchMissingPress = () => {
+ const {
+ missingAlbumIds,
+ onSearchMissingPress
+ } = this.props;
+
+ onSearchMissingPress(missingAlbumIds);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedFilterKey,
+ filters,
+ hasArtist,
+ artistError,
+ missingAlbumIds,
+ isSearchingForMissing,
+ useCurrentPage,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ isCalendarLinkModalOpen,
+ isOptionsModalOpen
+ } = this.state;
+
+ const isMeasured = this.state.width > 0;
+
+ const PageComponent = hasArtist ? CalendarConnector : NoArtist;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ artistError &&
+
+ {getErrorMessage(artistError, 'Failed to load artist from API')}
+
+ }
+
+ {
+ !artistError &&
+
+ {
+ isMeasured ?
+ :
+
+ }
+
+ }
+
+ {
+ hasArtist && !!artistError &&
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+CalendarPage.propTypes = {
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ hasArtist: PropTypes.bool.isRequired,
+ artistError: PropTypes.object,
+ missingAlbumIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ isSearchingForMissing: PropTypes.bool.isRequired,
+ useCurrentPage: PropTypes.bool.isRequired,
+ onSearchMissingPress: PropTypes.func.isRequired,
+ onDaysCountChange: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+export default CalendarPage;
diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js
new file mode 100644
index 000000000..db0f827c1
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPageConnector.js
@@ -0,0 +1,101 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import moment from 'moment';
+import { isCommandExecuting } from 'Utilities/Command';
+import isBefore from 'Utilities/Date/isBefore';
+import withCurrentPage from 'Components/withCurrentPage';
+import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
+import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import CalendarPage from './CalendarPage';
+
+function createMissingAlbumIdsSelector() {
+ return createSelector(
+ (state) => state.calendar.start,
+ (state) => state.calendar.end,
+ (state) => state.calendar.items,
+ (state) => state.queue.details.items,
+ (start, end, albums, queueDetails) => {
+ return albums.reduce((acc, album) => {
+ const releaseDate = album.releaseDate;
+
+ if (
+ album.percentOfTracks < 100 &&
+ moment(releaseDate).isAfter(start) &&
+ moment(releaseDate).isBefore(end) &&
+ isBefore(album.releaseDate) &&
+ !queueDetails.some((details) => !!details.album && details.album.id === album.id)
+ ) {
+ acc.push(album.id);
+ }
+
+ return acc;
+ }, []);
+ }
+ );
+}
+
+function createIsSearchingSelector() {
+ return createSelector(
+ (state) => state.calendar.searchMissingCommandId,
+ createCommandsSelector(),
+ (searchMissingCommandId, commands) => {
+ if (searchMissingCommandId == null) {
+ return false;
+ }
+
+ return isCommandExecuting(commands.find((command) => {
+ return command.id === searchMissingCommandId;
+ }));
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.selectedFilterKey,
+ (state) => state.calendar.filters,
+ createArtistCountSelector(),
+ createUISettingsSelector(),
+ createMissingAlbumIdsSelector(),
+ createIsSearchingSelector(),
+ (
+ selectedFilterKey,
+ filters,
+ artistCount,
+ uiSettings,
+ missingAlbumIds,
+ isSearchingForMissing
+ ) => {
+ return {
+ selectedFilterKey,
+ filters,
+ colorImpairedMode: uiSettings.enableColorImpairedMode,
+ hasArtist: !!artistCount.count,
+ artistError: artistCount.error,
+ missingAlbumIds,
+ isSearchingForMissing
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSearchMissingPress(albumIds) {
+ dispatch(searchMissing({ albumIds }));
+ },
+ onDaysCountChange(dayCount) {
+ dispatch(setCalendarDaysCount({ dayCount }));
+ },
+
+ onFilterSelect(selectedFilterKey) {
+ dispatch(setCalendarFilter({ selectedFilterKey }));
+ }
+ };
+}
+
+export default withCurrentPage(
+ connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
+);
diff --git a/frontend/src/Calendar/Day/CalendarDay.css b/frontend/src/Calendar/Day/CalendarDay.css
new file mode 100644
index 000000000..79eb67ae7
--- /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 $calendarBorderColor;
+ border-left: 1px solid $calendarBorderColor;
+}
+
+.isSingleDay {
+ width: 100%;
+}
+
+.dayOfMonth {
+ padding-right: 5px;
+ border-bottom: 1px solid $calendarBorderColor;
+ 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..bd196cc5d
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.js
@@ -0,0 +1,63 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
+import styles from './CalendarDay.css';
+
+function CalendarDay(props) {
+ const {
+ date,
+ time,
+ isTodaysDate,
+ events,
+ view,
+ onEventModalOpenToggle
+ } = props;
+
+ return (
+
+ {
+ view === calendarViews.MONTH &&
+
+ {moment(date).date()}
+
+ }
+
+ {
+ events.map((event) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+}
+
+CalendarDay.propTypes = {
+ date: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ isTodaysDate: PropTypes.bool.isRequired,
+ events: PropTypes.arrayOf(PropTypes.object).isRequired,
+ view: PropTypes.string.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarDay;
diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js
new file mode 100644
index 000000000..6206ef4c6
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDayConnector.js
@@ -0,0 +1,55 @@
+import _ from 'lodash';
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import CalendarDay from './CalendarDay';
+
+function createCalendarEventsConnector() {
+ return createSelector(
+ (state, { date }) => date,
+ (state) => state.calendar.items,
+ (date, items) => {
+ const filtered = _.filter(items, (item) => {
+ return moment(date).isSame(moment(item.releaseDate), 'day');
+ });
+
+ return _.sortBy(filtered, (item) => moment(item.releaseDate).unix());
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createCalendarEventsConnector(),
+ (calendar, events) => {
+ return {
+ time: calendar.time,
+ view: calendar.view,
+ events
+ };
+ }
+ );
+}
+
+class CalendarDayConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarDayConnector.propTypes = {
+ date: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(CalendarDayConnector);
diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css
new file mode 100644
index 000000000..b6dd2100c
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.css
@@ -0,0 +1,14 @@
+.days {
+ display: flex;
+ border-right: 1px solid $calendarBorderColor;
+}
+
+.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..055d51882
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.css
@@ -0,0 +1,79 @@
+.event {
+ overflow-x: hidden;
+ margin: 4px 2px;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 4px solid $borderColor;
+ font-size: 12px;
+
+ &:global(.colorImpaired) {
+ border-left-width: 5px;
+ }
+}
+
+.info,
+.albumInfo {
+ display: flex;
+}
+
+.artistName,
+.albumTitle {
+ @add-mixin truncate;
+
+ flex: 1 0 1px;
+ margin-right: 10px;
+}
+
+.artistName {
+ color: #3a3f51;
+ font-size: $defaultFontSize;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ border-left-color: $successColor !important;
+
+ &:global(.colorImpaired) {
+ border-left-color: color($successColor, saturation(+15%)) !important;
+ }
+}
+
+.downloading {
+ border-left-color: $purple !important;
+}
+
+.unmonitored {
+ border-left-color: $gray !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.missing {
+ border-left-color: $dangerColor !important;
+
+ &:global(.colorImpaired) {
+ border-left-color: color($dangerColor saturation(+15%)) !important;
+ background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ }
+}
+
+.unreleased {
+ border-left-color: $primaryColor !important;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 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..8f04fd670
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.js
@@ -0,0 +1,139 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import CalendarEventQueueDetails from './CalendarEventQueueDetails';
+import styles from './CalendarEvent.css';
+
+class CalendarEvent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isDetailsModalOpen: true }, () => {
+ this.props.onEventModalOpenToggle(true);
+ });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false }, () => {
+ this.props.onEventModalOpenToggle(false);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ artist,
+ title,
+ foreignAlbumId,
+ releaseDate,
+ monitored,
+ statistics,
+ grabbed,
+ queueItem,
+ // timeFormat,
+ colorImpairedMode
+ } = this.props;
+
+ if (!artist) {
+ return null;
+ }
+
+ const startTime = moment(releaseDate);
+ // const endTime = startTime.add(artist.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = artist.monitored && monitored;
+ const statusStyle = getStatusStyle(id, downloading, startTime, isMonitored, statistics.percentOfTracks);
+
+ return (
+
+
+
+
+
+ {artist.artistName}
+
+
+
+ {
+ !!queueItem &&
+
+
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+CalendarEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ artist: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ // timeFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+CalendarEvent.defaultProps = {
+ statistics: {
+ percentOfTracks: 0
+ }
+};
+
+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..31706e2f7
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarEvent from './CalendarEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (artist, queueItem, uiSettings) => {
+ return {
+ artist,
+ queueItem,
+ timeFormat: uiSettings.timeFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarEvent);
diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
new file mode 100644
index 000000000..1b603fd50
--- /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..4b6915406
--- /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..97052e0c8
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.js
@@ -0,0 +1,268 @@
+/* eslint max-params: 0 */
+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,
+ collapseViewButtons,
+ 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 &&
+
+ }
+
+ {
+ collapseViewButtons ?
+
+
+
+
+
+
+ {
+ isSmallScreen ?
+ null :
+
+ Month
+
+ }
+
+
+ Week
+
+
+
+ Forecast
+
+
+
+ Day
+
+
+
+ Agenda
+
+
+ :
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+ );
+ }
+}
+
+CalendarHeader.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ time: PropTypes.string.isRequired,
+ start: PropTypes.string.isRequired,
+ end: PropTypes.string.isRequired,
+ view: PropTypes.oneOf(calendarViews.all).isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ collapseViewButtons: 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..b73730ed9
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js
@@ -0,0 +1,85 @@
+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.collapseViewButtons = dimensions.isLargeScreen;
+ 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..cc69198fd
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.js
@@ -0,0 +1,91 @@
+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 {
+ showCutoffUnmetIcon,
+ colorImpairedMode
+ } = props;
+
+ const iconsToShow = [];
+
+ if (showCutoffUnmetIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {iconsToShow[0]}
+
+
+ {
+ iconsToShow.length > 1 &&
+
+ {iconsToShow[1]}
+ {iconsToShow[2]}
+
+ }
+
+ );
+}
+
+Legend.propTypes = {
+ 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..82e16c543
--- /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';
+}
+
+.partial {
+ composes: partial 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';
+}
+
+.unreleased {
+ composes: unreleased 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..a25d36f9c
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js
@@ -0,0 +1,216 @@
+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 {
+ collapseMultipleAlbums,
+ showCutoffUnmetIcon,
+ onModalClose
+ } = this.props;
+
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode
+ } = this.state;
+
+ return (
+
+
+ Calendar Options
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+CalendarOptionsModalContent.propTypes = {
+ collapseMultipleAlbums: PropTypes.bool.isRequired,
+ showCutoffUnmetIcon: PropTypes.bool.isRequired,
+ firstDayOfWeek: PropTypes.number.isRequired,
+ calendarWeekColumnHeader: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ enableColorImpairedMode: PropTypes.bool.isRequired,
+ dispatchSetCalendarOption: PropTypes.func.isRequired,
+ dispatchSaveUISettings: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarOptionsModalContent;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
new file mode 100644
index 000000000..eb979f74e
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setCalendarOption } from 'Store/Actions/calendarActions';
+import CalendarOptionsModalContent from './CalendarOptionsModalContent';
+import { saveUISettings } from 'Store/Actions/settingsActions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar.options,
+ (state) => state.settings.ui.item,
+ (options, uiSettings) => {
+ return {
+ ...options,
+ ...uiSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetCalendarOption: setCalendarOption,
+ dispatchSaveUISettings: saveUISettings
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);
diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js
new file mode 100644
index 000000000..929958b66
--- /dev/null
+++ b/frontend/src/Calendar/calendarViews.js
@@ -0,0 +1,7 @@
+export const DAY = 'day';
+export const WEEK = 'week';
+export const MONTH = 'month';
+export const FORECAST = 'forecast';
+export const AGENDA = 'agenda';
+
+export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js
new file mode 100644
index 000000000..c02ae0ee5
--- /dev/null
+++ b/frontend/src/Calendar/getStatusStyle.js
@@ -0,0 +1,30 @@
+/* eslint max-params: 0 */
+import moment from 'moment';
+
+function getStatusStyle(episodeNumber, downloading, startTime, isMonitored, percentOfTracks) {
+ const currentTime = moment();
+
+ if (percentOfTracks === 100) {
+ return 'downloaded';
+ }
+
+ if (percentOfTracks > 0) {
+ return 'partial';
+ }
+
+ if (downloading) {
+ return 'downloading';
+ }
+
+ if (!isMonitored) {
+ return 'unmonitored';
+ }
+
+ if (currentTime.isAfter(startTime)) {
+ return 'missing';
+ }
+
+ return 'unreleased';
+}
+
+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..074c66516
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
@@ -0,0 +1,213 @@
+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,
+ pastDays,
+ futureDays,
+ tags
+ } = state;
+
+ let icalUrl = `${window.location.host}${window.Lidarr.urlBase}/feed/v1/calendar/Lidarr.ics?`;
+
+ if (unmonitored) {
+ icalUrl += 'unmonitored=true&';
+ }
+
+ if (tags.length) {
+ icalUrl += `tags=${tags.toString()}&`;
+ }
+
+ icalUrl += `pastDays=${pastDays}&futureDays=${futureDays}&apikey=${window.Lidarr.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,
+ pastDays: 7,
+ futureDays: 28,
+ 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,
+ pastDays,
+ futureDays,
+ tags,
+ iCalHttpUrl,
+ iCalWebCalUrl
+ } = this.state;
+
+ return (
+
+
+ Lidarr 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..110f94939
--- /dev/null
+++ b/frontend/src/Commands/commandNames.js
@@ -0,0 +1,22 @@
+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_ALBUM_SEARCH = 'CutoffUnmetAlbumSearch';
+export const DELETE_LOG_FILES = 'DeleteLogFiles';
+export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles';
+export const DOWNLOADED_ALBUMS_SCAN = 'DownloadedAlbumsScan';
+export const ALBUM_SEARCH = 'AlbumSearch';
+export const INTERACTIVE_IMPORT = 'ManualImport';
+export const MISSING_ALBUM_SEARCH = 'MissingAlbumSearch';
+export const MOVE_ARTIST = 'MoveArtist';
+export const REFRESH_ARTIST = 'RefreshArtist';
+export const RENAME_FILES = 'RenameFiles';
+export const RENAME_ARTIST = 'RenameArtist';
+export const RETAG_FILES = 'RetagFiles';
+export const RETAG_ARTIST = 'RetagArtist';
+export const RESET_API_KEY = 'ResetApiKey';
+export const RSS_SYNC = 'RssSync';
+export const SEASON_SEARCH = 'AlbumSearch';
+export const ARTIST_SEARCH = 'ArtistSearch';
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..f373de1d6
--- /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.lidarrGreen,
+ 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..87fb2498a
--- /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.elementType.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..f99930437
--- /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..59dba1397
--- /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..7ddb9e806
--- /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: inputWrapper 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..ca84ac078
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
@@ -0,0 +1,257 @@
+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..da5ae2ab8
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.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 { 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 = {
+ dispatchFetchPaths: fetchPaths,
+ dispatchClearPaths: clearPaths
+};
+
+class FileBrowserModalContentConnector extends Component {
+
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ value,
+ includeFiles,
+ dispatchFetchPaths
+ } = this.props;
+
+ dispatchFetchPaths({
+ path: value,
+ allowFoldersWithoutTrailingSlashes: true,
+ includeFiles
+ });
+ }
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ const {
+ includeFiles,
+ dispatchFetchPaths
+ } = this.props;
+
+ dispatchFetchPaths({
+ path,
+ allowFoldersWithoutTrailingSlashes: true,
+ includeFiles
+ });
+ }
+
+ onClearPaths = () => {
+ // this.props.dispatchClearPaths();
+ }
+
+ onModalClose = () => {
+ this.props.dispatchClearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+FileBrowserModalContentConnector.propTypes = {
+ value: PropTypes.string,
+ includeFiles: PropTypes.bool.isRequired,
+ dispatchFetchPaths: PropTypes.func.isRequired,
+ dispatchClearPaths: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+FileBrowserModalContentConnector.defaultProps = {
+ includeFiles: false
+};
+
+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..9f111ed5d
--- /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/ArtistStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ArtistStatusFilterBuilderRowValue.js
new file mode 100644
index 000000000..28070d200
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/ArtistStatusFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: 'continuing', name: 'Continuing' },
+ { id: 'ended', name: 'Ended' }
+];
+
+function ArtistStatusFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default ArtistStatusFilterBuilderRowValue;
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..39db60700
--- /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..62c3f0197
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
@@ -0,0 +1,228 @@
+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,
+ onCancelPress,
+ 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,
+ onCancelPress: 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..26bc50192
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
@@ -0,0 +1,286 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
+import SelectInput from 'Components/Form/SelectInput';
+import IconButton from 'Components/Link/IconButton';
+import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
+import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
+import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
+import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
+import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector';
+import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
+import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
+import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
+import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue';
+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.METADATA_PROFILE:
+ return MetadataProfileFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.PROTOCOL:
+ return ProtocolFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.QUALITY:
+ return QualityFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.QUALITY_PROFILE:
+ return QualityProfileFilterBuilderRowValueConnector;
+
+ case filterBuilderValueTypes.ARTIST_STATUS:
+ return ArtistStatusFilterBuilderRowValue;
+
+ 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..ef6084c02
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
@@ -0,0 +1,160 @@
+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 tagShape from 'Helpers/Props/Shapes/tagShape';
+import TagInput 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..c8813284e
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
@@ -0,0 +1,60 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import sortByName from 'Utilities/Array/sortByName';
+import { filterBuilderTypes } from 'Helpers/Props';
+import * as filterTypes from 'Helpers/Props/filterTypes';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createTagListSelector() {
+ return createSelector(
+ (state, { filterType }) => filterType,
+ (state, { sectionItems }) => sectionItems,
+ (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp,
+ (filterType, sectionItems, selectedFilterBuilderProp) => {
+ if (
+ (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ||
+ selectedFilterBuilderProp.type === filterBuilderTypes.STRING) &&
+ filterType !== filterTypes.EQUAL &&
+ filterType !== filterBuilderTypes.NOT_EQUAL ||
+ !selectedFilterBuilderProp.optionsSelector
+ ) {
+ 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..9bf027af9
--- /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..0132ae641
--- /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 tagShape from 'Helpers/Props/Shapes/tagShape';
+import { fetchIndexers } from 'Store/Actions/settingsActions';
+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/MetadataProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..89d6c06b3
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.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.metadataProfiles,
+ (metadataProfiles) => {
+ const tagList = metadataProfiles.items.map((metadataProfile) => {
+ const {
+ id,
+ name
+ } = metadataProfile;
+
+ return {
+ id,
+ name
+ };
+ });
+
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js
new file mode 100644
index 000000000..ae63ae0eb
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const protocols = [
+ { id: 'torrent', name: 'Torrent' },
+ { id: 'usenet', name: 'Usenet' }
+];
+
+function ProtocolFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default ProtocolFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js
new file mode 100644
index 000000000..d0443bf19
--- /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 tagShape from 'Helpers/Props/Shapes/tagShape';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+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/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..fb2c13a12
--- /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) => {
+ 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..729f380e7
--- /dev/null
+++ b/frontend/src/Components/Filter/FilterModal.js
@@ -0,0 +1,102 @@
+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
+ });
+ }
+
+ onCancelPress = () => {
+ if (this.state.filterBuilder) {
+ this.setState({
+ filterBuilder: false,
+ id: null
+ });
+ } else {
+ this.onModalClose();
+ }
+ }
+
+ 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/AlbumReleaseSelectInputConnector.js b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js
new file mode 100644
index 000000000..b79c0db1d
--- /dev/null
+++ b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js
@@ -0,0 +1,70 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import titleCase from 'Utilities/String/titleCase';
+import SelectInput from './SelectInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { albumReleases }) => albumReleases,
+ (albumReleases) => {
+ const values = _.map(albumReleases.value, (albumRelease) => {
+
+ return {
+ key: albumRelease.foreignReleaseId,
+ value: `${albumRelease.title}` +
+ `${albumRelease.disambiguation ? ' (' : ''}${titleCase(albumRelease.disambiguation)}${albumRelease.disambiguation ? ')' : ''}` +
+ `, ${albumRelease.mediumCount} med, ${albumRelease.trackCount} tracks` +
+ `${albumRelease.country.length > 0 ? ', ' : ''}${albumRelease.country}` +
+ `${albumRelease.format ? ', [' : ''}${albumRelease.format}${albumRelease.format ? ']' : ''}`
+ };
+ });
+
+ const sortedValues = _.orderBy(values, ['value']);
+
+ const value = _.find(albumReleases.value, { monitored: true }).foreignReleaseId;
+
+ return {
+ values: sortedValues,
+ value
+ };
+ }
+ );
+}
+
+class AlbumReleaseSelectInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ const {
+ albumReleases
+ } = this.props;
+
+ const updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false }));
+ _.find(updatedReleases, { foreignReleaseId: value }).monitored = true;
+
+ this.props.onChange({ name, value: updatedReleases });
+ }
+
+ render() {
+
+ return (
+
+ );
+ }
+}
+
+AlbumReleaseSelectInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ albumReleases: PropTypes.object
+};
+
+export default connect(createMapStateToProps)(AlbumReleaseSelectInputConnector);
diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js
new file mode 100644
index 000000000..e19700d08
--- /dev/null
+++ b/frontend/src/Components/Form/AutoCompleteInput.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import jdu from 'jdu';
+import AutoSuggestInput from './AutoSuggestInput';
+
+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
+ });
+ }
+
+ 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 {
+ name,
+ value,
+ ...otherProps
+ } = this.props;
+
+ const { suggestions } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+AutoCompleteInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ values: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+AutoCompleteInput.defaultProps = {
+ value: ''
+};
+
+export default AutoCompleteInput;
diff --git a/frontend/src/Components/Form/AutoSuggestInput.css b/frontend/src/Components/Form/AutoSuggestInput.css
new file mode 100644
index 000000000..0f3279cb9
--- /dev/null
+++ b/frontend/src/Components/Form/AutoSuggestInput.css
@@ -0,0 +1,50 @@
+.input {
+ composes: input from '~Components/Form/Input.css';
+}
+
+.hasError {
+ composes: hasError from '~Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from '~Components/Form/Input.css';
+}
+
+.inputContainer {
+ flex-grow: 1;
+}
+
+.suggestionsContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.suggestionsContainerOpen {
+ z-index: $popperZIndex;
+
+ .suggestionsContainer {
+ 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;
+ }
+}
+
+.suggestionsList {
+ margin: 5px 0;
+ padding-left: 0;
+ max-height: 200px;
+ list-style-type: none;
+}
+
+.suggestion {
+ padding: 0 16px;
+}
+
+.suggestionHighlighted {
+ background-color: $menuItemHoverBackgroundColor;
+}
diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js
new file mode 100644
index 000000000..dd5833ee0
--- /dev/null
+++ b/frontend/src/Components/Form/AutoSuggestInput.js
@@ -0,0 +1,257 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import { Manager, Popper, Reference } from 'react-popper';
+import classNames from 'classnames';
+import Portal from 'Components/Portal';
+import styles from './AutoSuggestInput.css';
+
+class AutoSuggestInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scheduleUpdate = null;
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ this._scheduleUpdate &&
+ prevProps.suggestions !== this.props.suggestions
+ ) {
+ this._scheduleUpdate();
+ }
+ }
+
+ //
+ // Control
+
+ renderInputComponent = (inputProps) => {
+ const { renderInputComponent } = this.props;
+
+ return (
+
+ {({ ref }) => {
+ if (renderInputComponent) {
+ return renderInputComponent(inputProps, ref);
+ }
+
+ return (
+
+
+
+ );
+ }}
+
+ );
+ }
+
+ renderSuggestionsContainer = ({ containerProps, children }) => {
+ return (
+
+
+ {({ ref: popperRef, style, scheduleUpdate }) => {
+ this._scheduleUpdate = scheduleUpdate;
+
+ return (
+
+ );
+ }}
+
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onComputeMaxHeight = (data) => {
+ const {
+ top,
+ bottom,
+ width
+ } = data.offsets.reference;
+
+ const windowHeight = window.innerHeight;
+
+ if ((/^botton/).test(data.placement)) {
+ data.styles.maxHeight = windowHeight - bottom;
+ } else {
+ data.styles.maxHeight = top;
+ }
+
+ data.styles.width = width;
+
+ return data;
+ }
+
+ onInputChange = (event, { newValue }) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: newValue
+ });
+ }
+
+ onInputKeyDown = (event) => {
+ const {
+ name,
+ value,
+ suggestions,
+ onChange
+ } = this.props;
+
+ if (
+ event.key === 'Tab' &&
+ suggestions.length &&
+ suggestions[0] !== this.props.value
+ ) {
+ event.preventDefault();
+
+ if (value) {
+ onChange({
+ name,
+ value: suggestions[0]
+ });
+ }
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ forwardedRef,
+ className,
+ inputContainerClassName,
+ name,
+ value,
+ placeholder,
+ suggestions,
+ hasError,
+ hasWarning,
+ getSuggestionValue,
+ renderSuggestion,
+ onInputChange,
+ onInputKeyDown,
+ onInputFocus,
+ onInputBlur,
+ onSuggestionsFetchRequested,
+ onSuggestionsClearRequested,
+ onSuggestionSelected,
+ ...otherProps
+ } = this.props;
+
+ const inputProps = {
+ className: classNames(
+ className,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning
+ ),
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: onInputChange || this.onInputChange,
+ onKeyDown: onInputKeyDown || this.onInputKeyDown,
+ onFocus: onInputFocus,
+ onBlur: onInputBlur
+ };
+
+ const theme = {
+ container: inputContainerClassName,
+ containerOpen: styles.suggestionsContainerOpen,
+ suggestionsContainer: styles.suggestionsContainer,
+ suggestionsList: styles.suggestionsList,
+ suggestion: styles.suggestion,
+ suggestionHighlighted: styles.suggestionHighlighted
+ };
+
+ return (
+
+
+
+ );
+ }
+}
+
+AutoSuggestInput.propTypes = {
+ forwardedRef: PropTypes.func,
+ className: PropTypes.string.isRequired,
+ inputContainerClassName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+ placeholder: PropTypes.string,
+ suggestions: PropTypes.array.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ enforceMaxHeight: PropTypes.bool.isRequired,
+ minHeight: PropTypes.number.isRequired,
+ maxHeight: PropTypes.number.isRequired,
+ getSuggestionValue: PropTypes.func.isRequired,
+ renderInputComponent: PropTypes.elementType,
+ renderSuggestion: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func,
+ onInputKeyDown: PropTypes.func,
+ onInputFocus: PropTypes.func,
+ onInputBlur: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func,
+ onChange: PropTypes.func.isRequired
+};
+
+AutoSuggestInput.defaultProps = {
+ className: styles.input,
+ inputContainerClassName: styles.inputContainer,
+ enforceMaxHeight: true,
+ minHeight: 50,
+ maxHeight: 200
+};
+
+export default AutoSuggestInput;
diff --git a/frontend/src/Components/Form/CaptchaInput.css b/frontend/src/Components/Form/CaptchaInput.css
new file mode 100644
index 000000000..76c076834
--- /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..e0b05eca3
--- /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..7abe83db5
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInput.css
@@ -0,0 +1,8 @@
+.deviceInputWrapper {
+ display: flex;
+}
+
+.input {
+ composes: input 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..f77c7cf29
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInput.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import tagShape from 'Helpers/Props/Shapes/tagShape';
+import Icon from 'Components/Icon';
+import FormInputButton from './FormInputButton';
+import TagInput 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,
+ name,
+ 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..43e313826
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInputConnector.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchOptions, clearOptions } from 'Store/Actions/providerOptionActions';
+import DeviceInput from './DeviceInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ (state) => state.providerOptions,
+ (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 = {
+ dispatchFetchOptions: fetchOptions,
+ dispatchClearOptions: clearOptions
+};
+
+class DeviceInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ this._populate();
+ }
+
+ componentWillUnmount = () => {
+ this.props.dispatchClearOptions();
+ }
+
+ //
+ // Control
+
+ _populate() {
+ const {
+ provider,
+ providerData,
+ dispatchFetchOptions
+ } = this.props;
+
+ dispatchFetchOptions({
+ action: 'getDevices',
+ 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,
+ dispatchFetchOptions: PropTypes.func.isRequired,
+ dispatchClearOptions: 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..774a63517
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css
@@ -0,0 +1,78 @@
+.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 {
+ z-index: $popperZIndex;
+ width: auto;
+}
+
+.options {
+ composes: scroller from '~Components/Scroller/Scroller.css';
+
+ 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..80ee78e81
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -0,0 +1,449 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Manager, Popper, Reference } from 'react-popper';
+import classNames from 'classnames';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import { isMobile as isMobileUtil } from 'Utilities/mobile';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { icons, sizes, scrollDirections } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Portal from 'Components/Portal';
+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 HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
+import HintedSelectInputOption from './HintedSelectInputOption';
+import styles from './EnhancedSelectInput.css';
+
+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._scheduleUpdate = null;
+ this._buttonId = getUniqueElememtId();
+ this._optionsId = getUniqueElememtId();
+
+ this.state = {
+ isOpen: false,
+ selectedIndex: getSelectedIndex(props),
+ width: 0,
+ isMobile: isMobileUtil()
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this._scheduleUpdate) {
+ this._scheduleUpdate();
+ }
+
+ if (prevProps.value !== this.props.value) {
+ this.setState({
+ selectedIndex: getSelectedIndex(this.props)
+ });
+ }
+ }
+
+ //
+ // Control
+
+ _addListener() {
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onComputeMaxHeight = (data) => {
+ const {
+ top,
+ bottom
+ } = data.offsets.reference;
+
+ const windowHeight = window.innerHeight;
+
+ if ((/^botton/).test(data.placement)) {
+ data.styles.maxHeight = windowHeight - bottom;
+ } else {
+ data.styles.maxHeight = top;
+ }
+
+ return data;
+ }
+
+ onWindowClick = (event) => {
+ const button = document.getElementById(this._buttonId);
+ const options = document.getElementById(this._optionsId);
+
+ 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 = () => {
+ // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
+ const origIndex = getSelectedIndex(this.props);
+ if (origIndex !== this.state.selectedIndex) {
+ this.setState({ selectedIndex: origIndex });
+ }
+ }
+
+ 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 (
+
+
+
+ {({ ref }) => (
+
+ )}
+
+
+
+ {({ ref, style, scheduleUpdate }) => {
+ this._scheduleUpdate = scheduleUpdate;
+
+ return (
+
+ {
+ isOpen && !isMobile ?
+
+ {
+ values.map((v, index) => {
+ return (
+
+ {v.value}
+
+ );
+ })
+ }
+ :
+ null
+ }
+
+ );
+ }
+ }
+
+
+
+
+ {
+ 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.elementType,
+ onChange: PropTypes.func.isRequired
+};
+
+EnhancedSelectInput.defaultProps = {
+ className: styles.enhancedSelect,
+ disabledClassName: styles.isDisabled,
+ isDisabled: false,
+ selectedValueOptions: {},
+ selectedValueComponent: HintedSelectInputSelectedValue,
+ optionComponent: HintedSelectInputOption
+};
+
+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..18440c50d
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css
@@ -0,0 +1,45 @@
+.option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 5px 10px;
+ width: 100%;
+ cursor: default;
+
+ &:hover {
+ background-color: #f8f8f8;
+ }
+}
+
+.isSelected {
+ background-color: #e2e2e2;
+
+ &:hover {
+ 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..da4888f09
--- /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..1a1b104e6
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.css
@@ -0,0 +1,51 @@
+.inputGroupContainer {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.inputGroup {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+}
+
+.inputContainer {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.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..5b5dd2792
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -0,0 +1,269 @@
+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 PlaylistInputConnector from './PlaylistInputConnector';
+import KeyValueListInput from './KeyValueListInput';
+import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput';
+import NumberInput from './NumberInput';
+import OAuthInputConnector from './OAuthInputConnector';
+import PasswordInput from './PasswordInput';
+import PathInputConnector from './PathInputConnector';
+import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
+import MetadataProfileSelectInputConnector from './MetadataProfileSelectInputConnector';
+import AlbumReleaseSelectInputConnector from './AlbumReleaseSelectInputConnector';
+import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
+import SeriesTypeSelectInput from './SeriesTypeSelectInput';
+import EnhancedSelectInput from './EnhancedSelectInput';
+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.PLAYLIST:
+ return PlaylistInputConnector;
+
+ case inputTypes.KEY_VALUE_LIST:
+ return KeyValueListInput;
+
+ case inputTypes.MONITOR_ALBUMS_SELECT:
+ return MonitorAlbumsSelectInput;
+
+ 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.METADATA_PROFILE_SELECT:
+ return MetadataProfileSelectInputConnector;
+
+ case inputTypes.ALBUM_RELEASE_SELECT:
+ return AlbumReleaseSelectInputConnector;
+
+ case inputTypes.ROOT_FOLDER_SELECT:
+ return RootFolderSelectInputConnector;
+
+ case inputTypes.SELECT:
+ return EnhancedSelectInput;
+
+ case inputTypes.SERIES_TYPE_SELECT:
+ return SeriesTypeSelectInput;
+
+ case inputTypes.TAG:
+ return TagInputConnector;
+
+ case inputTypes.TEXT_TAG:
+ return TextTagInputConnector;
+
+ default:
+ return TextInput;
+ }
+}
+
+function FormInputGroup(props) {
+ const {
+ className,
+ containerClassName,
+ inputClassName,
+ type,
+ unit,
+ buttons,
+ helpText,
+ helpTexts,
+ helpTextWarning,
+ helpLink,
+ pending,
+ errors,
+ warnings,
+ ...otherProps
+ } = props;
+
+ const InputComponent = getComponent(type);
+ const checkInput = type === inputTypes.CHECK;
+ const hasError = !!errors.length;
+ const hasWarning = !hasError && !!warnings.length;
+ const buttonsArray = React.Children.toArray(buttons);
+ const lastButtonIndex = buttonsArray.length - 1;
+ const hasButton = !!buttonsArray.length;
+
+ return (
+
+
+
+
+
+ {
+ unit &&
+
+ {unit}
+
+ }
+
+
+ {
+ buttonsArray.map((button, index) => {
+ return React.cloneElement(
+ button,
+ {
+ isLastButton: index === lastButtonIndex
+ }
+ );
+ })
+ }
+
+ {/*
+ {
+ pending &&
+
+ }
+
*/}
+
+
+ {
+ !checkInput && helpText &&
+
+ }
+
+ {
+ !checkInput && helpTexts &&
+
+ {
+ helpTexts.map((text, index) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !checkInput && helpTextWarning &&
+
+ }
+
+ {
+ helpLink &&
+
+ More Info
+
+ }
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+FormInputGroup.propTypes = {
+ className: PropTypes.string.isRequired,
+ containerClassName: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ unit: PropTypes.string,
+ buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
+ helpText: PropTypes.string,
+ helpTexts: PropTypes.arrayOf(PropTypes.string),
+ helpTextWarning: PropTypes.string,
+ helpLink: PropTypes.string,
+ pending: PropTypes.bool,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object)
+};
+
+FormInputGroup.defaultProps = {
+ className: styles.inputGroup,
+ containerClassName: styles.inputGroupContainer,
+ type: inputTypes.TEXT,
+ buttons: [],
+ helpTexts: [],
+ errors: [],
+ warnings: []
+};
+
+export default FormInputGroup;
diff --git a/frontend/src/Components/Form/FormInputHelpText.css b/frontend/src/Components/Form/FormInputHelpText.css
new file mode 100644
index 000000000..7fd957233
--- /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/HintedSelectInputOption.css b/frontend/src/Components/Form/HintedSelectInputOption.css
new file mode 100644
index 000000000..74d1fb088
--- /dev/null
+++ b/frontend/src/Components/Form/HintedSelectInputOption.css
@@ -0,0 +1,23 @@
+.optionText {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 1 0 0;
+ min-width: 0;
+
+ &.isMobile {
+ display: block;
+
+ .hintText {
+ margin-left: 0;
+ }
+ }
+}
+
+.hintText {
+ @add-mixin truncate;
+
+ margin-left: 15px;
+ color: $darkGray;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.js b/frontend/src/Components/Form/HintedSelectInputOption.js
new file mode 100644
index 000000000..5ccc48a13
--- /dev/null
+++ b/frontend/src/Components/Form/HintedSelectInputOption.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import EnhancedSelectInputOption from './EnhancedSelectInputOption';
+import styles from './HintedSelectInputOption.css';
+
+function HintedSelectInputOption(props) {
+ const {
+ value,
+ hint,
+ isMobile,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
{value}
+
+ {
+ hint != null &&
+
+ {hint}
+
+ }
+
+
+ );
+}
+
+HintedSelectInputOption.propTypes = {
+ value: PropTypes.string.isRequired,
+ hint: PropTypes.node,
+ isMobile: PropTypes.bool.isRequired
+};
+
+export default HintedSelectInputOption;
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.css b/frontend/src/Components/Form/HintedSelectInputSelectedValue.css
new file mode 100644
index 000000000..a31970a9e
--- /dev/null
+++ b/frontend/src/Components/Form/HintedSelectInputSelectedValue.css
@@ -0,0 +1,24 @@
+.selectedValue {
+ composes: selectedValue from '~./EnhancedSelectInputSelectedValue.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.valueText {
+ @add-mixin truncate;
+
+ flex: 0 0 auto;
+}
+
+.hintText {
+ @add-mixin truncate;
+
+ flex: 1 10 0;
+ margin-left: 15px;
+ color: $gray;
+ text-align: right;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
new file mode 100644
index 000000000..d43c3e4da
--- /dev/null
+++ b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import styles from './HintedSelectInputSelectedValue.css';
+
+function HintedSelectInputSelectedValue(props) {
+ const {
+ value,
+ hint,
+ includeHint,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {value}
+
+
+ {
+ hint != null && includeHint &&
+
+ {hint}
+
+ }
+
+ );
+}
+
+HintedSelectInputSelectedValue.propTypes = {
+ value: PropTypes.string,
+ hint: PropTypes.string,
+ includeHint: PropTypes.bool.isRequired
+};
+
+HintedSelectInputSelectedValue.defaultProps = {
+ includeHint: true
+};
+
+export default HintedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Input.css b/frontend/src/Components/Form/Input.css
new file mode 100644
index 000000000..e9ca23d8f
--- /dev/null
+++ b/frontend/src/Components/Form/Input.css
@@ -0,0 +1,30 @@
+.input {
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+
+ &:focus {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.hasError {
+ border-color: $inputErrorBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputErrorBoxShadowColor;
+}
+
+.hasWarning {
+ border-color: $inputWarningBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputWarningBoxShadowColor;
+}
+
+.hasButton {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
diff --git a/frontend/src/Components/Form/KeyValueListInput.css b/frontend/src/Components/Form/KeyValueListInput.css
new file mode 100644
index 000000000..8bf23610b
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.css
@@ -0,0 +1,21 @@
+.inputContainer {
+ composes: input from '~Components/Form/Input.css';
+
+ position: relative;
+ min-height: 35px;
+ height: auto;
+
+ &.isFocused {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.hasError {
+ composes: hasError from '~Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from '~Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js
new file mode 100644
index 000000000..a52c76f70
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.js
@@ -0,0 +1,152 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import KeyValueListInputItem from './KeyValueListInputItem';
+import styles from './KeyValueListInput.css';
+
+class KeyValueListInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isFocused: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onItemChange = (index, itemValue) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = [...value];
+
+ if (index == null) {
+ newValue.push(itemValue);
+ } else {
+ newValue.splice(index, 1, itemValue);
+ }
+
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ onRemoveItem = (index) => {
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = [...value];
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ onFocus = () => {
+ this.setState({
+ isFocused: true
+ });
+ }
+
+ onBlur = () => {
+ this.setState({
+ isFocused: false
+ });
+
+ const {
+ name,
+ value,
+ onChange
+ } = this.props;
+
+ const newValue = value.reduce((acc, v) => {
+ if (v.key || v.value) {
+ acc.push(v);
+ }
+
+ return acc;
+ }, []);
+
+ if (newValue.length !== value.length) {
+ onChange({
+ name,
+ value: newValue
+ });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ value,
+ keyPlaceholder,
+ valuePlaceholder
+ } = this.props;
+
+ const { isFocused } = this.state;
+
+ return (
+
+ {
+ [...value, { key: '', value: '' }].map((v, index) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+KeyValueListInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.object).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ keyPlaceholder: PropTypes.string,
+ valuePlaceholder: PropTypes.string,
+ onChange: PropTypes.func.isRequired
+};
+
+KeyValueListInput.defaultProps = {
+ className: styles.inputContainer,
+ value: []
+};
+
+export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css
new file mode 100644
index 000000000..f77ea3470
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css
@@ -0,0 +1,14 @@
+.itemContainer {
+ display: flex;
+ margin-bottom: 3px;
+ border-bottom: 1px solid $inputBorderColor;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.keyInput,
+.valueInput {
+ border: none;
+}
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js
new file mode 100644
index 000000000..4e465f3a9
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import TextInput from './TextInput';
+import styles from './KeyValueListInputItem.css';
+
+class KeyValueListInputItem extends Component {
+
+ //
+ // Listeners
+
+ onKeyChange = ({ value: keyValue }) => {
+ const {
+ index,
+ value,
+ onChange
+ } = this.props;
+
+ onChange(index, { key: keyValue, value });
+ }
+
+ onValueChange = ({ value }) => {
+ // TODO: Validate here or validate at a lower level component
+
+ const {
+ index,
+ keyValue,
+ onChange
+ } = this.props;
+
+ onChange(index, { key: keyValue, value });
+ }
+
+ onRemovePress = () => {
+ const {
+ index,
+ onRemove
+ } = this.props;
+
+ onRemove(index);
+ }
+
+ onFocus = () => {
+ this.props.onFocus();
+ }
+
+ onBlur = () => {
+ this.props.onBlur();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ keyValue,
+ value,
+ keyPlaceholder,
+ valuePlaceholder,
+ isNew
+ } = this.props;
+
+ return (
+
+
+
+
+
+ {
+ !isNew &&
+
+ }
+
+ );
+ }
+}
+
+KeyValueListInputItem.propTypes = {
+ index: PropTypes.number,
+ keyValue: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ keyPlaceholder: PropTypes.string.isRequired,
+ valuePlaceholder: PropTypes.string.isRequired,
+ isNew: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onRemove: PropTypes.func.isRequired,
+ onFocus: PropTypes.func.isRequired,
+ onBlur: PropTypes.func.isRequired
+};
+
+KeyValueListInputItem.defaultProps = {
+ keyPlaceholder: 'Key',
+ valuePlaceholder: 'Value'
+};
+
+export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js b/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js
new file mode 100644
index 000000000..d28876aa0
--- /dev/null
+++ b/frontend/src/Components/Form/MetadataProfileSelectInputConnector.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.metadataProfiles,
+ (state, { includeNoChange }) => includeNoChange,
+ (state, { includeMixed }) => includeMixed,
+ (metadataProfiles, includeNoChange, includeMixed) => {
+ const values = _.map(metadataProfiles.items.sort(sortByName), (metadataProfile) => {
+ return {
+ key: metadataProfile.id,
+ value: metadataProfile.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 MetadataProfileSelectInputConnector 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 (
+
+ );
+ }
+}
+
+MetadataProfileSelectInputConnector.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
+};
+
+MetadataProfileSelectInputConnector.defaultProps = {
+ includeNoChange: false
+};
+
+export default connect(createMapStateToProps)(MetadataProfileSelectInputConnector);
diff --git a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js
new file mode 100644
index 000000000..a3780de56
--- /dev/null
+++ b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import monitorOptions from 'Utilities/Artist/monitorOptions';
+import SelectInput from './SelectInput';
+
+function MonitorAlbumsSelectInput(props) {
+ const {
+ includeNoChange,
+ includeMixed,
+ ...otherProps
+ } = props;
+
+ const values = [...monitorOptions];
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return (
+
+ );
+}
+
+MonitorAlbumsSelectInput.propTypes = {
+ includeNoChange: PropTypes.bool.isRequired,
+ includeMixed: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+MonitorAlbumsSelectInput.defaultProps = {
+ includeNoChange: false,
+ includeMixed: false
+};
+
+export default MonitorAlbumsSelectInput;
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..6cb162784
--- /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..3b32b16f0
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.css
@@ -0,0 +1,18 @@
+.hasFileBrowser {
+ composes: input from '~./AutoSuggestInput.css';
+ composes: hasButton from '~Components/Form/Input.css';
+}
+
+.inputWrapper {
+ display: flex;
+}
+
+.pathMatch {
+ font-weight: bold;
+}
+
+.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..40c4840ba
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.js
@@ -0,0 +1,195 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import AutoSuggestInput from './AutoSuggestInput';
+import FormInputButton from './FormInputButton';
+import styles from './PathInput.css';
+
+class PathInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._node = document.getElementById('portal-root');
+
+ this.state = {
+ value: props.value,
+ isFileBrowserModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const { value } = this.props;
+
+ if (prevProps.value !== value) {
+ this.setState({ value });
+ }
+ }
+
+ //
+ // 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 = ({ value }) => {
+ this.setState({ value });
+ }
+
+ 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.onChange({
+ name: this.props.name,
+ value: this.state.value
+ });
+
+ 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,
+ name,
+ paths,
+ includeFiles,
+ hasFileBrowser,
+ onChange,
+ ...otherProps
+ } = this.props;
+
+ const {
+ value,
+ isFileBrowserModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ hasFileBrowser &&
+
+
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+PathInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ paths: PropTypes.array.isRequired,
+ includeFiles: PropTypes.bool.isRequired,
+ hasFileBrowser: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onFetchPaths: PropTypes.func.isRequired,
+ onClearPaths: PropTypes.func.isRequired
+};
+
+PathInput.defaultProps = {
+ className: styles.inputWrapper,
+ 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..38ea37065
--- /dev/null
+++ b/frontend/src/Components/Form/PathInputConnector.js
@@ -0,0 +1,80 @@
+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 = {
+ dispatchFetchPaths: fetchPaths,
+ dispatchClearPaths: clearPaths
+};
+
+class PathInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ const {
+ includeFiles,
+ dispatchFetchPaths
+ } = this.props;
+
+ dispatchFetchPaths({
+ path,
+ includeFiles
+ });
+ }
+
+ onClearPaths = () => {
+ this.props.dispatchClearPaths();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+PathInputConnector.propTypes = {
+ includeFiles: PropTypes.bool.isRequired,
+ dispatchFetchPaths: PropTypes.func.isRequired,
+ dispatchClearPaths: PropTypes.func.isRequired
+};
+
+PathInputConnector.defaultProps = {
+ includeFiles: false
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);
diff --git a/frontend/src/Components/Form/PlaylistInput.css b/frontend/src/Components/Form/PlaylistInput.css
new file mode 100644
index 000000000..078d3beac
--- /dev/null
+++ b/frontend/src/Components/Form/PlaylistInput.css
@@ -0,0 +1,9 @@
+.playlistInputWrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.input {
+ composes: input from '~./TagInput.css';
+ composes: hasButton from '~Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/PlaylistInput.js b/frontend/src/Components/Form/PlaylistInput.js
new file mode 100644
index 000000000..df482e7bb
--- /dev/null
+++ b/frontend/src/Components/Form/PlaylistInput.js
@@ -0,0 +1,186 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import tagShape from 'Helpers/Props/Shapes/tagShape';
+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 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 TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import styles from './PlaylistInput.css';
+
+const columns = [
+ {
+ name: 'name',
+ label: 'Playlist',
+ isSortable: false,
+ isVisible: true
+ }
+];
+
+class PlaylistInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const initialSelection = _.mapValues(_.keyBy(props.value), () => true);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ selectedState: initialSelection
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ name,
+ onChange
+ } = this.props;
+
+ const oldSelected = getSelectedIds(prevState.selectedState, { parseIds: false }).sort();
+ const newSelected = this.getSelectedIds().sort();
+
+ if (!_.isEqual(oldSelected, newSelected)) {
+ onChange({
+ name,
+ value: newSelected
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState, { parseIds: false });
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state, props) => {
+ return toggleSelected(state, props.items, id, value, shiftKey);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ items,
+ user,
+ isFetching,
+ isPopulated
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isPopulated && !isFetching &&
+
+ Authenticate with spotify to retrieve playlists to import.
+
+ }
+
+ {
+ isPopulated && !isFetching && !user &&
+
+ Could not retrieve data from Spotify. Try re-authenticating.
+
+ }
+
+ {
+ isPopulated && !isFetching && user && !items.length &&
+
+ No playlists found for Spotify user {user}.
+
+ }
+
+ {
+ isPopulated && !isFetching && user && !!items.length &&
+
+ Select playlists to import from Spotify user {user}.
+
+
+ {
+ items.map((item) => {
+ return (
+
+
+
+
+ {item.name}
+
+
+ );
+ })
+ }
+
+
+
+ }
+
+ );
+ }
+}
+
+PlaylistInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
+ user: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+PlaylistInput.defaultProps = {
+ className: styles.playlistInputWrapper,
+ inputClassName: styles.input
+};
+
+export default PlaylistInput;
diff --git a/frontend/src/Components/Form/PlaylistInputConnector.js b/frontend/src/Components/Form/PlaylistInputConnector.js
new file mode 100644
index 000000000..e70765671
--- /dev/null
+++ b/frontend/src/Components/Form/PlaylistInputConnector.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 { fetchOptions, clearOptions } from 'Store/Actions/providerOptionActions';
+import PlaylistInput from './PlaylistInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.providerOptions,
+ (state) => {
+ const {
+ items,
+ ...otherState
+ } = state;
+ return ({
+ user: items.user ? items.user : '',
+ items: items.playlists ? items.playlists : [],
+ ...otherState
+ });
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchOptions: fetchOptions,
+ dispatchClearOptions: clearOptions
+};
+
+class PlaylistInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (this._getAccessToken(this.props)) {
+ this._populate();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const newToken = this._getAccessToken(this.props);
+ const oldToken = this._getAccessToken(prevProps);
+ if (newToken && newToken !== oldToken) {
+ this._populate();
+ }
+ }
+
+ componentWillUnmount = () => {
+ this.props.dispatchClearOptions();
+ }
+
+ //
+ // Control
+
+ _populate() {
+ const {
+ provider,
+ providerData,
+ dispatchFetchOptions
+ } = this.props;
+
+ dispatchFetchOptions({
+ action: 'getPlaylists',
+ provider,
+ providerData
+ });
+ }
+
+ _getAccessToken(props) {
+ return _.filter(props.providerData.fields, { name: 'accessToken' })[0].value;
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+PlaylistInputConnector.propTypes = {
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ name: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ dispatchFetchOptions: PropTypes.func.isRequired,
+ dispatchClearOptions: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PlaylistInputConnector);
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
new file mode 100644
index 000000000..dca32aa1e
--- /dev/null
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -0,0 +1,133 @@
+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 'playlist':
+ return inputTypes.PLAYLIST;
+ case 'password':
+ return inputTypes.PASSWORD;
+ case 'number':
+ return inputTypes.NUMBER;
+ case 'path':
+ return inputTypes.PATH;
+ case 'filePath':
+ 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,
+ hidden,
+ pending,
+ errors,
+ warnings,
+ selectOptions,
+ onChange,
+ ...otherProps
+ } = props;
+
+ if (
+ hidden === 'hidden' ||
+ (hidden === 'hiddenIfNotSet' && !value)
+ ) {
+ return null;
+ }
+
+ 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,
+ hidden: PropTypes.string,
+ 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..b76501dc1
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
@@ -0,0 +1,136 @@
+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 = rootFolders.items.map((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 || !values.some((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..6b0cf9e4f
--- /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..aa1dfc79b
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.css
@@ -0,0 +1,18 @@
+.select {
+ composes: input from '~Components/Form/Input.css';
+
+ padding: 0 11px;
+}
+
+.hasError {
+ composes: hasError from '~Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from '~Components/Form/Input.css';
+}
+
+.isDisabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js
new file mode 100644
index 000000000..113d50a09
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.js
@@ -0,0 +1,95 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './SelectInput.css';
+
+class SelectInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: event.target.value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ disabledClassName,
+ name,
+ value,
+ values,
+ isDisabled,
+ hasError,
+ hasWarning,
+ autoFocus,
+ onBlur
+ } = this.props;
+
+ return (
+
+ {
+ values.map((option) => {
+ const {
+ key,
+ value: optionValue,
+ ...otherOptionProps
+ } = option;
+
+ return (
+
+ {optionValue}
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+SelectInput.propTypes = {
+ className: PropTypes.string,
+ disabledClassName: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDisabled: PropTypes.bool,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ autoFocus: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onBlur: PropTypes.func
+};
+
+SelectInput.defaultProps = {
+ className: styles.select,
+ disabledClassName: styles.isDisabled,
+ isDisabled: false,
+ autoFocus: false
+};
+
+export default SelectInput;
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.js b/frontend/src/Components/Form/SeriesTypeSelectInput.js
new file mode 100644
index 000000000..4fe0a974c
--- /dev/null
+++ b/frontend/src/Components/Form/SeriesTypeSelectInput.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectInput from './SelectInput';
+
+const artistTypeOptions = [
+ { key: 'standard', value: 'Standard' },
+ { key: 'daily', value: 'Daily' },
+ { key: 'anime', value: 'Anime' }
+];
+
+function SeriesTypeSelectInput(props) {
+ const values = [...artistTypeOptions];
+
+ const {
+ includeNoChange,
+ includeMixed
+ } = props;
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return (
+
+ );
+}
+
+SeriesTypeSelectInput.propTypes = {
+ includeNoChange: PropTypes.bool.isRequired,
+ includeMixed: PropTypes.bool.isRequired
+};
+
+SeriesTypeSelectInput.defaultProps = {
+ includeNoChange: false,
+ includeMixed: false
+};
+
+export default SeriesTypeSelectInput;
diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/TagInput.css
new file mode 100644
index 000000000..1516bfb1d
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.css
@@ -0,0 +1,23 @@
+.input {
+ composes: input from '~./AutoSuggestInput.css';
+
+ padding: 0;
+ min-height: 35px;
+ height: auto;
+
+ &.isFocused {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.internalInput {
+ flex: 1 1 0%;
+ margin-left: 3px;
+ min-width: 20%;
+ max-width: 100%;
+ width: 0%;
+ height: 31px;
+ border: none;
+}
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
new file mode 100644
index 000000000..45d972631
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.js
@@ -0,0 +1,280 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import tagShape from 'Helpers/Props/Shapes/tagShape';
+import AutoSuggestInput from './AutoSuggestInput';
+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 = suggestions.find((suggestion) => suggestion.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, forwardedRef) => {
+ const {
+ tags,
+ kind,
+ tagComponent,
+ onTagDelete
+ } = this.props;
+
+ return (
+
+ );
+ }
+
+ render() {
+ const {
+ className,
+ inputContainerClassName,
+ ...otherProps
+ } = this.props;
+
+ const {
+ value,
+ suggestions,
+ isFocused
+ } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+TagInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ inputContainerClassName: 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.elementType.isRequired,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired
+};
+
+TagInput.defaultProps = {
+ className: styles.internalInput,
+ inputContainerClassName: 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..292f1a089
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputInput.css
@@ -0,0 +1,12 @@
+.inputContainer {
+ top: -1px;
+ right: -1px;
+ bottom: -1px;
+ left: -1px;
+ display: flex;
+ align-items: start;
+ flex-wrap: wrap;
+ padding: 1px 16px;
+ min-height: 33px;
+ cursor: default;
+}
diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js
new file mode 100644
index 000000000..3e28830e9
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputInput.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import tagShape from 'Helpers/Props/Shapes/tagShape';
+import styles from './TagInputInput.css';
+
+class TagInputInput extends Component {
+
+ onMouseDown = (event) => {
+ event.preventDefault();
+
+ const {
+ isFocused,
+ onInputContainerPress
+ } = this.props;
+
+ if (isFocused) {
+ return;
+ }
+
+ onInputContainerPress();
+ }
+
+ render() {
+ const {
+ forwardedRef,
+ className,
+ tags,
+ inputProps,
+ kind,
+ tagComponent: TagComponent,
+ onTagDelete
+ } = this.props;
+
+ return (
+
+ {
+ tags.map((tag, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+TagInputInput.propTypes = {
+ forwardedRef: PropTypes.func,
+ 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.elementType.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.css b/frontend/src/Components/Form/TagInputTag.css
new file mode 100644
index 000000000..bf08e13fc
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputTag.css
@@ -0,0 +1,5 @@
+.tag {
+ composes: link from '~Components/Link/Link.css';
+
+ height: 31px;
+}
diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js
new file mode 100644
index 000000000..f5935ad7b
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputTag.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import tagShape from 'Helpers/Props/Shapes/tagShape';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import styles from './TagInputTag.css';
+
+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..80503704d
--- /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..cc0cbca02
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.js
@@ -0,0 +1,194 @@
+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,
+ min,
+ max,
+ 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,
+ min: PropTypes.number,
+ max: 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..d7748d2e7
--- /dev/null
+++ b/frontend/src/Components/Icon.js
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } 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 PureComponent {
+
+ //
+ // Render
+
+ 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..df17427d9
--- /dev/null
+++ b/frontend/src/Components/Label.css
@@ -0,0 +1,111 @@
+.label {
+ display: inline-block;
+ margin: 2px;
+ border: 1px solid;
+ border-radius: 2px;
+ color: $white;
+ text-align: center;
+ white-space: nowrap;
+ line-height: 1;
+ cursor: default;
+}
+
+/** Kinds **/
+
+.danger {
+ border-color: $dangerColor;
+ background-color: $dangerColor;
+
+ &.outline {
+ color: $dangerColor;
+ }
+}
+
+.default {
+ border-color: $themeLightColor;
+ background-color: $themeLightColor;
+
+ &.outline {
+ color: $themeLightColor;
+ }
+}
+
+.disabled {
+ border-color: $disabledColor;
+ background-color: $disabledColor;
+
+ &.outline {
+ color: $disabledColor;
+ }
+}
+
+.info {
+ border-color: $infoColor;
+ background-color: $infoColor;
+
+ &.outline {
+ color: $infoColor;
+ }
+}
+
+.inverse {
+ border-color: $lightGray;
+ background-color: $lightGray;
+ color: $defaultColor;
+
+ &.outline {
+ background-color: $defaultColor !important;
+ color: $lightGray;
+ }
+}
+
+.primary {
+ border-color: $primaryColor;
+ background-color: $primaryColor;
+
+ &.outline {
+ color: $primaryColor;
+ }
+}
+
+.success {
+ border-color: $successColor;
+ background-color: $successColor;
+
+ &.outline {
+ color: $successColor;
+ }
+}
+
+.warning {
+ border-color: $warningColor;
+ background-color: $warningColor;
+
+ &.outline {
+ color: $warningColor;
+ }
+}
+
+/** Sizes **/
+
+.small {
+ padding: 1px 3px;
+ font-size: 11px;
+}
+
+.medium {
+ padding: 2px 5px;
+ font-size: 12px;
+}
+
+.large {
+ padding: 3px 7px;
+ font-weight: bold;
+ font-size: $defaultFontSize;
+}
+
+/** Outline **/
+
+.outline {
+ background-color: $white;
+}
diff --git a/frontend/src/Components/Label.js b/frontend/src/Components/Label.js
new file mode 100644
index 000000000..528974204
--- /dev/null
+++ b/frontend/src/Components/Label.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds, sizes } from 'Helpers/Props';
+import styles from './Label.css';
+
+function Label(props) {
+ const {
+ className,
+ kind,
+ size,
+ outline,
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+Label.propTypes = {
+ className: PropTypes.string.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ size: PropTypes.oneOf(sizes.all).isRequired,
+ outline: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired
+};
+
+Label.defaultProps = {
+ className: styles.label,
+ kind: kinds.DEFAULT,
+ size: sizes.SMALL,
+ outline: false
+};
+
+export default Label;
diff --git a/frontend/src/Components/Link/Button.css b/frontend/src/Components/Link/Button.css
new file mode 100644
index 000000000..d5b7e8200
--- /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..438489155
--- /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..2061243ee
--- /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..b7487545c
--- /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.Lidarr.urlBase}/`)) {
+ el = RouterLink;
+ linkProps.to = to;
+ linkProps.target = target;
+ } else {
+ el = RouterLink;
+ linkProps.to = `${window.Lidarr.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..2a2044c25
--- /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..1671053f1
--- /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..a4ca95beb
--- /dev/null
+++ b/frontend/src/Components/Loading/LoadingMessage.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import styles from './LoadingMessage.css';
+
+const messages = [
+ 'Downloading more RAM',
+ 'Now in Technicolor',
+ 'Previously on Lidarr...',
+ 'Bleep Bloop.',
+ 'Locating the required gigapixels to render...',
+ 'Spinning up the hamster wheel...',
+ 'At least you\'re not on hold',
+ 'Hum something loud while others stare',
+ 'Loading humorous message... Please Wait',
+ 'I could\'ve been faster in Python',
+ 'Don\'t forget to rewind your tracks',
+ 'Congratulations! you are the 1000th visitor.',
+ 'HELP! I\'m being held hostage and forced to write these stupid lines!',
+ 'RE-calibrating the internet...',
+ 'I\'ll be here all week',
+ 'Don\'t forget to tip your waitress',
+ 'Apply directly to the forehead',
+ 'Loading Battlestation'
+];
+
+let message = null;
+
+function LoadingMessage() {
+ if (!message) {
+ const index = Math.floor(Math.random() * messages.length);
+ 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..881dbe26c
--- /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..d989605e5
--- /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.elementType.isRequired,
+ filterModalConnectorComponent: PropTypes.elementType,
+ 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..963ea62cb
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.css
@@ -0,0 +1,3 @@
+.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..fadbcc69e
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.js
@@ -0,0 +1,252 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Manager, Popper, Reference } from 'react-popper';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import { align } from 'Helpers/Props';
+import Portal from 'Components/Portal';
+import styles from './Menu.css';
+
+const sharedPopperOptions = {
+ modifiers: {
+ preventOverflow: {
+ padding: 0
+ },
+ flip: {
+ padding: 0
+ }
+ }
+};
+
+const popperOptions = {
+ [align.RIGHT]: {
+ ...sharedPopperOptions,
+ placement: 'bottom-end'
+ },
+
+ [align.LEFT]: {
+ ...sharedPopperOptions,
+ placement: 'bottom-start'
+ }
+};
+
+class Menu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scheduleUpdate = null;
+ this._menuButtonId = getUniqueElememtId();
+ this._menuContentId = getUniqueElememtId();
+
+ this.state = {
+ isMenuOpen: false,
+ maxHeight: 0
+ };
+ }
+
+ componentDidMount() {
+ this.setMaxHeight();
+ }
+
+ componentDidUpdate() {
+ if (this._scheduleUpdate) {
+ this._scheduleUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ this._removeListener();
+ }
+
+ //
+ // Control
+
+ getMaxHeight() {
+ if (!this.props.enforceMaxHeight) {
+ return;
+ }
+
+ const menuButton = document.getElementById(this._menuButtonId);
+
+ if (!menuButton) {
+ return;
+ }
+
+ const { bottom } = menuButton.getBoundingClientRect();
+ const maxHeight = window.innerHeight - bottom;
+
+ return maxHeight;
+ }
+
+ setMaxHeight() {
+ const maxHeight = this.getMaxHeight();
+
+ if (maxHeight !== this.state.maxHeight) {
+ this.setState({
+ maxHeight
+ });
+ }
+ }
+
+ _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);
+ window.addEventListener('touchstart', this.onTouchStart);
+ }
+
+ _removeListener() {
+ window.removeEventListener('resize', this.onWindowResize);
+ window.removeEventListener('scroll', this.onWindowScroll, { capture: true });
+ window.removeEventListener('click', this.onWindowClick);
+ window.removeEventListener('touchstart', this.onTouchStart);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const menuButton = document.getElementById(this._menuButtonId);
+
+ if (!menuButton) {
+ return;
+ }
+
+ if (!menuButton.contains(event.target) && this.state.isMenuOpen) {
+ this.setState({ isMenuOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onTouchStart = (event) => {
+ const menuButton = document.getElementById(this._menuButtonId);
+ const menuContent = document.getElementById(this._menuContentId);
+
+ if (!menuButton || !menuContent) {
+ return;
+ }
+
+ if (event.targetTouches.length !== 1) {
+ return;
+ }
+
+ const target = event.targetTouches[0].target;
+
+ if (
+ !menuButton.contains(target) &&
+ !menuContent.contains(target) &&
+ this.state.isMenuOpen
+ ) {
+ this.setState({ isMenuOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onWindowResize = () => {
+ this.setMaxHeight();
+ }
+
+ onWindowScroll = (event) => {
+ if (this.state.isMenuOpen) {
+ 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
+ }
+ );
+
+ return (
+
+
+ {({ ref }) => (
+
+ )}
+
+
+
+
+ {({ ref, style, scheduleUpdate }) => {
+ this._scheduleUpdate = scheduleUpdate;
+
+ return React.cloneElement(
+ childrenArray[1],
+ {
+ forwardedRef: ref,
+ style: {
+ ...style,
+ maxHeight
+ },
+ isOpen: isMenuOpen
+ }
+ );
+ }}
+
+
+
+ );
+ }
+}
+
+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..b9327fdd7
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.css
@@ -0,0 +1,12 @@
+.menuContent {
+ z-index: $popperZIndex;
+ 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..e158187d5
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.js
@@ -0,0 +1,53 @@
+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 {
+ forwardedRef,
+ className,
+ id,
+ children,
+ style,
+ isOpen
+ } = this.props;
+
+ return (
+
+ {
+ isOpen ?
+
+ {children}
+ :
+ null
+ }
+
+ );
+ }
+}
+
+MenuContent.propTypes = {
+ forwardedRef: PropTypes.func,
+ className: PropTypes.string,
+ id: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ style: PropTypes.object,
+ isOpen: PropTypes.bool
+};
+
+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..2eb2817af
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItem.css
@@ -0,0 +1,23 @@
+.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;
+ }
+}
+
+.isDisabled {
+ color: $disabledColor;
+ pointer-events: none;
+}
diff --git a/frontend/src/Components/Menu/MenuItem.js b/frontend/src/Components/Menu/MenuItem.js
new file mode 100644
index 000000000..fb1c1d4ec
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItem.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import Link from 'Components/Link/Link';
+import styles from './MenuItem.css';
+
+class MenuItem extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ isDisabled,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+MenuItem.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ isDisabled: PropTypes.bool.isRequired
+};
+
+MenuItem.defaultProps = {
+ className: styles.menuItem,
+ isDisabled: false
+};
+
+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..e48e7f16f
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuItemSeparator.css
@@ -0,0 +1,6 @@
+.separator {
+ overflow: hidden;
+ min-height: 1px;
+ 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..d979a1708
--- /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..71e966c71
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.css
@@ -0,0 +1,16 @@
+.menuButton {
+ composes: menuButton from '~./MenuButton.css';
+
+ padding-top: 4px;
+ width: $toolbarButtonWidth;
+ height: $toolbarHeight;
+ text-align: center;
+}
+
+.labelContainer {
+ composes: labelContainer from '~Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.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..fe06793f6
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.js
@@ -0,0 +1,40 @@
+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..d7269ea46
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.css
@@ -0,0 +1,98 @@
+.modalContainer {
+ position: absolute;
+ top: 0;
+ z-index: $modalZIndex;
+ 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;
+}
+
+.modalOpenIOS {
+ position: fixed;
+ right: 0;
+ left: 0;
+}
+
+/*
+ * 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..3b6485ce3
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.js
@@ -0,0 +1,232 @@
+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 { isIOS } from 'Utilities/mobile';
+import { setScrollLock } from 'Utilities/scrollLock';
+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('portal-root');
+ this._backgroundRef = null;
+ this._modalId = getUniqueElememtId();
+ this._bodyScrollTop = 0;
+ }
+
+ 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) {
+ if (isIOS()) {
+ setScrollLock(true);
+ const scrollTop = document.body.scrollTop;
+ this._bodyScrollTop = scrollTop;
+ elementClass(document.body).add(styles.modalOpenIOS);
+ } else {
+ elementClass(document.body).add(styles.modalOpen);
+ }
+ }
+ }
+
+ _closeModal() {
+ removeFromOpenModals(this._modalId);
+ window.removeEventListener('keydown', this.onKeyDown);
+
+ if (openModals.length === 0) {
+ setScrollLock(false);
+
+ if (isIOS()) {
+ elementClass(document.body).remove(styles.modalOpenIOS);
+ document.body.scrollTop = this._bodyScrollTop;
+ } else {
+ 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..6edde4790
--- /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.all)
+};
+
+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..1556240c6
--- /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..09b64f1ab
--- /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..c92db9bc0
--- /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 artist 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..ad982df8a
--- /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..c72e73673
--- /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..4440cf3be
--- /dev/null
+++ b/frontend/src/Components/Page/ErrorPage.js
@@ -0,0 +1,64 @@
+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,
+ artistError,
+ customFiltersError,
+ tagsError,
+ qualityProfilesError,
+ metadataProfilesError,
+ uiSettingsError,
+ systemStatusError
+ } = props;
+
+ let errorMessage = 'Failed to load Lidarr';
+
+ if (!isLocalStorageSupported) {
+ errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
+ } else if (artistError) {
+ errorMessage = getErrorMessage(artistError, 'Failed to load artist 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 (metadataProfilesError) {
+ errorMessage = getErrorMessage(metadataProfilesError, 'Failed to load metadata profiles from API');
+ } else if (uiSettingsError) {
+ errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API');
+ } else if (systemStatusError) {
+ errorMessage = getErrorMessage(uiSettingsError, 'Failed to load system status from API');
+ }
+
+ return (
+
+
+ {errorMessage}
+
+
+
+ Version {version}
+
+
+ );
+}
+
+ErrorPage.propTypes = {
+ version: PropTypes.string.isRequired,
+ isLocalStorageSupported: PropTypes.bool.isRequired,
+ artistError: PropTypes.object,
+ customFiltersError: PropTypes.object,
+ tagsError: PropTypes.object,
+ qualityProfilesError: PropTypes.object,
+ metadataProfilesError: PropTypes.object,
+ uiSettingsError: PropTypes.object,
+ systemStatusError: PropTypes.object
+};
+
+export default ErrorPage;
diff --git a/frontend/src/Components/Page/Header/ArtistSearchInput.css b/frontend/src/Components/Page/Header/ArtistSearchInput.css
new file mode 100644
index 000000000..7043de6c5
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchInput.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;
+}
+
+.artistContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.containerOpen {
+ .artistContainer {
+ 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: $primaryHoverBackgroundColor;
+}
+
+.sectionTitle {
+ padding: 5px 8px;
+ color: $disabledColor;
+}
+
+.addNewArtistSuggestion {
+ 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/ArtistSearchInput.js b/frontend/src/Components/Page/Header/ArtistSearchInput.js
new file mode 100644
index 000000000..eb22640ce
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchInput.js
@@ -0,0 +1,259 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import Fuse from 'fuse.js';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import ArtistSearchResult from './ArtistSearchResult';
+import styles from './ArtistSearchInput.css';
+
+const ADD_NEW_TYPE = 'addNew';
+
+const fuseOptions = {
+ shouldSort: true,
+ includeMatches: true,
+ threshold: 0.3,
+ location: 0,
+ distance: 100,
+ maxPatternLength: 32,
+ minMatchCharLength: 1,
+ keys: [
+ 'artistName',
+ 'tags.label'
+ ]
+};
+
+class ArtistSearchInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._autosuggest = null;
+
+ this.state = {
+ value: '',
+ suggestions: []
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.ARTIST_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 (
+
+ );
+ }
+
+ goToArtist(item) {
+ this.setState({ value: '' });
+ this.props.onGoToArtist(item.item.foreignArtistId);
+ }
+
+ 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' || event.key !== 'ArrowDown' || event.key !== 'ArrowUp') {
+ return;
+ }
+
+ const {
+ suggestions,
+ value
+ } = this.state;
+
+ const {
+ highlightedSectionIndex,
+ highlightedSuggestionIndex
+ } = this._autosuggest.state;
+
+ if (!suggestions.length || highlightedSectionIndex && (event.key !== 'ArrowDown' || event.key !== 'ArrowUp')) {
+ this.props.onGoToAddNewArtist(value);
+ this._autosuggest.input.blur();
+ this.reset();
+
+ return;
+ }
+
+ // If an suggestion is not selected go to the first artist,
+ // otherwise go to the selected artist.
+
+ if (highlightedSuggestionIndex == null && (event.key !== 'ArrowDown' || event.key !== 'ArrowUp')) {
+ this.goToArtist(suggestions[0]);
+ } else {
+ this.goToArtist(suggestions[highlightedSuggestionIndex]);
+ }
+
+ this._autosuggest.input.blur();
+ this.reset();
+ }
+
+ onBlur = () => {
+ this.reset();
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const fuse = new Fuse(this.props.artists, fuseOptions);
+ const suggestions = fuse.search(value).slice(0, 15);
+
+ this.setState({ suggestions });
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.setState({
+ suggestions: []
+ });
+ }
+
+ onSuggestionSelected = (event, { suggestion }) => {
+ if (suggestion.type === ADD_NEW_TYPE) {
+ this.props.onGoToAddNewArtist(this.state.value);
+ } else {
+ this.goToArtist(suggestion);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const suggestionGroups = [];
+
+ if (suggestions.length) {
+ suggestionGroups.push({
+ title: 'Existing Artist',
+ suggestions
+ });
+ }
+
+ suggestionGroups.push({
+ title: 'Add New Artist',
+ suggestions: [
+ {
+ type: ADD_NEW_TYPE,
+ title: value
+ }
+ ]
+ });
+
+ const inputProps = {
+ ref: this.setInputRef,
+ className: styles.input,
+ name: 'artistSearch',
+ 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.artistContainer,
+ suggestionsList: styles.list,
+ suggestion: styles.listItem,
+ suggestionHighlighted: styles.highlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+ArtistSearchInput.propTypes = {
+ artists: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onGoToArtist: PropTypes.func.isRequired,
+ onGoToAddNewArtist: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(ArtistSearchInput);
diff --git a/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js
new file mode 100644
index 000000000..214303358
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js
@@ -0,0 +1,66 @@
+import { connect } from 'react-redux';
+import { push } from 'connected-react-router';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import ArtistSearchInput from './ArtistSearchInput';
+
+function createCleanArtistSelector() {
+ return createSelector(
+ createAllArtistSelector(),
+ createTagsSelector(),
+ (allArtists, allTags) => {
+ return allArtists.map((artist) => {
+ const {
+ artistName,
+ sortName,
+ images,
+ foreignArtistId,
+ tags = []
+ } = artist;
+
+ return {
+ artistName,
+ sortName,
+ foreignArtistId,
+ images,
+ tags: tags.reduce((acc, id) => {
+ const matchingTag = allTags.find((tag) => tag.id === id);
+
+ if (matchingTag) {
+ acc.push(matchingTag);
+ }
+
+ return acc;
+ }, [])
+ };
+ });
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createDeepEqualSelector(
+ createCleanArtistSelector(),
+ (artists) => {
+ return {
+ artists
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onGoToArtist(foreignArtistId) {
+ dispatch(push(`${window.Lidarr.urlBase}/artist/${foreignArtistId}`));
+ },
+
+ onGoToAddNewArtist(query) {
+ dispatch(push(`${window.Lidarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistSearchInput);
diff --git a/frontend/src/Components/Page/Header/ArtistSearchResult.css b/frontend/src/Components/Page/Header/ArtistSearchResult.css
new file mode 100644
index 000000000..4d21d4640
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchResult.css
@@ -0,0 +1,38 @@
+.result {
+ display: flex;
+ padding: 3px;
+ cursor: pointer;
+}
+
+.poster {
+ width: 35px;
+ height: 35px;
+}
+
+.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/ArtistSearchResult.js b/frontend/src/Components/Page/Header/ArtistSearchResult.js
new file mode 100644
index 000000000..9e8511918
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchResult.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import ArtistPoster from 'Artist/ArtistPoster';
+import styles from './ArtistSearchResult.css';
+
+function ArtistSearchResult(props) {
+ const {
+ match,
+ artistName,
+ images,
+ tags
+ } = props;
+
+ let tag = null;
+
+ if (match.key === 'tags.label') {
+ tag = tags[match.arrayIndex];
+ }
+
+ return (
+
+
+
+
+
+ {artistName}
+
+
+ {
+ tag ?
+
+
+ {tag.label}
+
+
:
+ null
+ }
+
+
+ );
+}
+
+ArtistSearchResult.propTypes = {
+ artistName: PropTypes.string.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.object).isRequired,
+ match: PropTypes.object.isRequired
+};
+
+export default ArtistSearchResult;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js
new file mode 100644
index 000000000..a1d106b58
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector';
+
+function KeyboardShortcutsModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+KeyboardShortcutsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default KeyboardShortcutsModal;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css
new file mode 100644
index 000000000..4425e0e0d
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css
@@ -0,0 +1,15 @@
+.shortcut {
+ display: flex;
+ justify-content: space-between;
+ padding: 5px 20px;
+ font-size: 18px;
+}
+
+.key {
+ padding: 2px 4px;
+ border-radius: 3px;
+ background-color: $defaultColor;
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);
+ color: $white;
+ font-size: 16px;
+}
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js
new file mode 100644
index 000000000..9c07e047c
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { shortcuts } from 'Components/keyboardShortcuts';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './KeyboardShortcutsModalContent.css';
+
+function getShortcuts() {
+ const allShortcuts = [];
+
+ Object.keys(shortcuts).forEach((key) => {
+ allShortcuts.push(shortcuts[key]);
+ });
+
+ return allShortcuts;
+}
+
+function getShortcutKey(combo, isOsx) {
+ const comboMatch = combo.match(/(.+?)\+(.)/);
+
+ if (!comboMatch) {
+ return combo;
+ }
+
+ const modifier = comboMatch[1];
+ const key = comboMatch[2];
+ let osModifier = modifier;
+
+ if (modifier === 'mod') {
+ osModifier = isOsx ? 'cmd' : 'ctrl';
+ }
+
+ return `${osModifier} + ${key}`;
+}
+
+function KeyboardShortcutsModalContent(props) {
+ const {
+ isOsx,
+ onModalClose
+ } = props;
+
+ const allShortcuts = getShortcuts();
+
+ return (
+
+
+ Keyboard Shortcuts
+
+
+
+ {
+ allShortcuts.map((shortcut) => {
+ return (
+
+
+ {getShortcutKey(shortcut.key, isOsx)}
+
+
+
+ {shortcut.name}
+
+
+ );
+ })
+ }
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+KeyboardShortcutsModalContent.propTypes = {
+ isOsx: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default KeyboardShortcutsModalContent;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js
new file mode 100644
index 000000000..d80877153
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSystemStatusSelector(),
+ (systemStatus) => {
+ return {
+ isOsx: systemStatus.isOsx
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(KeyboardShortcutsModalContent);
diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css
new file mode 100644
index 000000000..c4dc3f844
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.css
@@ -0,0 +1,65 @@
+.header {
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ height: $headerHeight;
+ background-color: $themeAlternateBlue;
+ color: $white;
+}
+
+.logoContainer {
+ display: flex;
+ align-items: center;
+ flex: 0 0 $sidebarWidth;
+ padding-left: 20px;
+}
+
+.logoLink {
+ line-height: 0;
+}
+
+.logo {
+ width: 32px;
+ height: 32px;
+}
+
+.sidebarToggleContainer {
+ display: none;
+ justify-content: center;
+ flex: 0 0 45px;
+ margin-right: 14px;
+}
+
+.right {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+}
+
+.donate {
+ composes: link from '~Components/Link/Link.css';
+
+ width: 30px;
+ color: $themeRed;
+ text-align: center;
+ line-height: 60px;
+
+ &:hover {
+ color: #9c1f30;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .logoContainer {
+ flex: 0 0 60px;
+ }
+
+ .sidebarToggleContainer {
+ display: flex;
+ }
+
+ .donate {
+ display: none;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js
new file mode 100644
index 000000000..87cf317b3
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import ArtistSearchInputConnector from './ArtistSearchInputConnector';
+import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
+import KeyboardShortcutsModal from './KeyboardShortcutsModal';
+import styles from './PageHeader.css';
+
+class PageHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props);
+
+ this.state = {
+ isKeyboardShortcutsModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
+ }
+
+ //
+ // Control
+
+ onOpenKeyboardShortcutsModal = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: true });
+ }
+
+ //
+ // Listeners
+
+ onKeyboardShortcutsModalClose = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onSidebarToggle
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+PageHeader.propTypes = {
+ onSidebarToggle: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(PageHeader);
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
new file mode 100644
index 000000000..0fee43911
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
@@ -0,0 +1,20 @@
+.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..ae97e6be2
--- /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..fc782dc0c
--- /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..4f871f864
--- /dev/null
+++ b/frontend/src/Components/Page/Page.js
@@ -0,0 +1,135 @@
+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..7d0dded6b
--- /dev/null
+++ b/frontend/src/Components/Page/PageConnector.js
@@ -0,0 +1,265 @@
+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 { fetchArtist } from 'Store/Actions/artistActions';
+import { fetchTags } from 'Store/Actions/tagActions';
+import { fetchQualityProfiles, fetchMetadataProfiles, fetchUISettings, fetchImportLists } 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 = 'lidarrTest';
+
+ try {
+ localStorage.setItem(key, key);
+ localStorage.removeItem(key);
+
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+const selectAppProps = createSelector(
+ (state) => state.app.isSidebarVisible,
+ (state) => state.app.version,
+ (state) => state.app.isUpdated,
+ (state) => state.app.isDisconnected,
+ (isSidebarVisible, version, isUpdated, isDisconnected) => {
+ return {
+ isSidebarVisible,
+ version,
+ isUpdated,
+ isDisconnected
+ };
+ }
+);
+
+const selectIsPopulated = createSelector(
+ (state) => state.customFilters.isPopulated,
+ (state) => state.tags.isPopulated,
+ (state) => state.settings.ui.isPopulated,
+ (state) => state.settings.qualityProfiles.isPopulated,
+ (state) => state.settings.metadataProfiles.isPopulated,
+ (state) => state.settings.importLists.isPopulated,
+ (state) => state.system.status.isPopulated,
+ (
+ customFiltersIsPopulated,
+ tagsIsPopulated,
+ uiSettingsIsPopulated,
+ qualityProfilesIsPopulated,
+ metadataProfilesIsPopulated,
+ importListsIsPopulated,
+ systemStatusIsPopulated
+ ) => {
+ return (
+ customFiltersIsPopulated &&
+ tagsIsPopulated &&
+ uiSettingsIsPopulated &&
+ qualityProfilesIsPopulated &&
+ metadataProfilesIsPopulated &&
+ importListsIsPopulated &&
+ systemStatusIsPopulated
+ );
+ }
+);
+
+const selectErrors = createSelector(
+ (state) => state.customFilters.error,
+ (state) => state.tags.error,
+ (state) => state.settings.ui.error,
+ (state) => state.settings.qualityProfiles.error,
+ (state) => state.settings.metadataProfiles.error,
+ (state) => state.settings.importLists.error,
+ (state) => state.system.status.error,
+ (
+ customFiltersError,
+ tagsError,
+ uiSettingsError,
+ qualityProfilesError,
+ metadataProfilesError,
+ importListsError,
+ systemStatusError
+ ) => {
+ const hasError = !!(
+ customFiltersError ||
+ tagsError ||
+ uiSettingsError ||
+ qualityProfilesError ||
+ metadataProfilesError ||
+ importListsError ||
+ systemStatusError
+ );
+
+ return {
+ hasError,
+ customFiltersError,
+ tagsError,
+ uiSettingsError,
+ qualityProfilesError,
+ metadataProfilesError,
+ importListsError,
+ systemStatusError
+ };
+ }
+);
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.ui.item.enableColorImpairedMode,
+ selectIsPopulated,
+ selectErrors,
+ selectAppProps,
+ createDimensionsSelector(),
+ (
+ enableColorImpairedMode,
+ isPopulated,
+ errors,
+ app,
+ dimensions
+ ) => {
+ return {
+ ...app,
+ ...errors,
+ isPopulated,
+ isSmallScreen: dimensions.isSmallScreen,
+ enableColorImpairedMode
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchArtist() {
+ dispatch(fetchArtist());
+ },
+ dispatchFetchCustomFilters() {
+ dispatch(fetchCustomFilters());
+ },
+ dispatchFetchTags() {
+ dispatch(fetchTags());
+ },
+ dispatchFetchQualityProfiles() {
+ dispatch(fetchQualityProfiles());
+ },
+ dispatchFetchMetadataProfiles() {
+ dispatch(fetchMetadataProfiles());
+ },
+ dispatchFetchImportLists() {
+ dispatch(fetchImportLists());
+ },
+ 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.dispatchFetchArtist();
+ this.props.dispatchFetchCustomFilters();
+ this.props.dispatchFetchTags();
+ this.props.dispatchFetchQualityProfiles();
+ this.props.dispatchFetchMetadataProfiles();
+ this.props.dispatchFetchImportLists();
+ this.props.dispatchFetchUISettings();
+ this.props.dispatchFetchStatus();
+ }
+ }
+
+ //
+ // Listeners
+
+ onSidebarToggle = () => {
+ this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isPopulated,
+ hasError,
+ dispatchFetchArtist,
+ dispatchFetchTags,
+ dispatchFetchQualityProfiles,
+ dispatchFetchMetadataProfiles,
+ dispatchFetchImportLists,
+ 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,
+ dispatchFetchArtist: PropTypes.func.isRequired,
+ dispatchFetchCustomFilters: PropTypes.func.isRequired,
+ dispatchFetchTags: PropTypes.func.isRequired,
+ dispatchFetchQualityProfiles: PropTypes.func.isRequired,
+ dispatchFetchMetadataProfiles: PropTypes.func.isRequired,
+ dispatchFetchImportLists: 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..e7a650bb4
--- /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..5c277d8f5
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBody.js
@@ -0,0 +1,66 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { isLocked } from 'Utilities/scrollLock';
+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 {
+
+ //
+ // Listeners
+
+ onScroll = (props) => {
+ const { onScroll } = this.props;
+
+ if (this.props.onScroll && !isLocked()) {
+ onScroll(props);
+ }
+ }
+
+ //
+ // 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,
+ onScroll: PropTypes.func,
+ 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..e864ea7eb
--- /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, null, null, { forwardRef: true })(PageContentBody);
diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css
new file mode 100644
index 000000000..811e61c85
--- /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..41df52dfc
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBar.js
@@ -0,0 +1,141 @@
+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 ||
+ nextState.visibleItems !== this.state.visibleItems
+ );
+ }
+
+ 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..bb7a027fa
--- /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 'AlbumSearch':
+ return icons.SEARCH;
+ case 'Housekeeping':
+ return icons.HOUSEKEEPING;
+ case 'RefreshArtist':
+ return icons.REFRESH;
+ case 'RssSync':
+ return icons.RSS;
+ case 'SeasonSearch':
+ return icons.SEARCH;
+ case 'ArtistSearch':
+ 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..3f2abeee7
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.css
@@ -0,0 +1,33 @@
+.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..6ea0c3086
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js
@@ -0,0 +1,533 @@
+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.ARTIST_CONTINUING,
+ title: 'Library',
+ to: '/',
+ alias: '/artist',
+ children: [
+ {
+ title: 'Add New',
+ to: '/add/new'
+ },
+ {
+ title: 'Import',
+ to: '/add/import'
+ },
+ {
+ title: 'Mass Editor',
+ to: '/artisteditor'
+ },
+ {
+ title: 'Album Studio',
+ to: '/albumstudio'
+ },
+ {
+ title: 'Unmapped Files',
+ to: '/unmapped'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.CALENDAR,
+ title: 'Calendar',
+ to: '/calendar'
+ },
+
+ {
+ iconName: icons.ACTIVITY,
+ title: 'Activity',
+ to: '/activity/queue',
+ children: [
+ {
+ title: 'Queue',
+ to: '/activity/queue',
+ statusComponent: QueueStatusConnector
+ },
+ {
+ title: 'History',
+ to: '/activity/history'
+ },
+ {
+ title: 'Blacklist',
+ to: '/activity/blacklist'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.WARNING,
+ title: 'Wanted',
+ to: '/wanted/missing',
+ children: [
+ {
+ title: 'Missing',
+ to: '/wanted/missing'
+ },
+ {
+ title: 'Cutoff Unmet',
+ to: '/wanted/cutoffunmet'
+ }
+ ]
+ },
+
+ {
+ iconName: icons.SETTINGS,
+ title: 'Settings',
+ to: '/settings',
+ children: [
+ {
+ title: 'Media Management',
+ to: '/settings/mediamanagement'
+ },
+ {
+ title: 'Profiles',
+ to: '/settings/profiles'
+ },
+ {
+ title: 'Quality',
+ to: '/settings/quality'
+ },
+ {
+ title: 'Indexers',
+ to: '/settings/indexers'
+ },
+ {
+ title: 'Download Clients',
+ to: '/settings/downloadclients'
+ },
+ {
+ title: 'Import Lists',
+ to: '/settings/importlists'
+ },
+ {
+ 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.Lidarr.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..0bcc28cde
--- /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.elementType,
+ 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..2d914be43
--- /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..e729ed000
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
@@ -0,0 +1,33 @@
+.toolbarButton {
+ composes: link from '~Components/Link/Link.css';
+
+ padding-top: 4px;
+ width: $toolbarButtonWidth;
+ text-align: center;
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+
+ &.isDisabled {
+ color: $disabledColor;
+ }
+}
+
+.isDisabled {
+ color: $disabledColor;
+}
+
+.labelContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 24px;
+}
+
+.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..110675b99
--- /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: stretch;
+ 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/Portal.js b/frontend/src/Components/Portal.js
new file mode 100644
index 000000000..2e5237093
--- /dev/null
+++ b/frontend/src/Components/Portal.js
@@ -0,0 +1,18 @@
+import PropTypes from 'prop-types';
+import ReactDOM from 'react-dom';
+
+function Portal(props) {
+ const { children, target } = props;
+ return ReactDOM.createPortal(children, target);
+}
+
+Portal.propTypes = {
+ children: PropTypes.node.isRequired,
+ target: PropTypes.object.isRequired
+};
+
+Portal.defaultProps = {
+ target: document.getElementById('portal-root')
+};
+
+export default Portal;
diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css
new file mode 100644
index 000000000..777187eec
--- /dev/null
+++ b/frontend/src/Components/ProgressBar.css
@@ -0,0 +1,101 @@
+.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;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px);
+ }
+}
+
+.success {
+ background-color: $successColor;
+}
+
+.purple {
+ background-color: $purple;
+}
+
+.warning {
+ background-color: $warningColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
+ }
+}
+
+.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..3c16792fa
--- /dev/null
+++ b/frontend/src/Components/ProgressBar.js
@@ -0,0 +1,111 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds, sizes } from 'Helpers/Props';
+import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
+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 (
+
+ {(enableColorImpairedMode) => {
+ return (
+
+ {
+ showText && width ?
+
:
+ null
+ }
+
+
+
+ {
+ showText ?
+
:
+ null
+ }
+
+ );
+ }}
+
+ );
+}
+
+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..139b1e779
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.css
@@ -0,0 +1,15 @@
+.scroller {
+ /* Placeholder */
+}
+
+.thumb {
+ min-height: 100px;
+ 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..e2a269bdc
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.js
@@ -0,0 +1,171 @@
+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';
+
+const SCROLLBAR_SIZE = 10;
+
+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 (
+
+ );
+ }
+
+ _renderTrackHorizontal = ({ style, props }) => {
+ const finalStyle = {
+ ...style,
+ right: 2,
+ bottom: 2,
+ left: 2,
+ borderRadius: 3,
+ height: SCROLLBAR_SIZE
+ };
+
+ return (
+
+ );
+ }
+
+ _renderTrackVertical = ({ style, props }) => {
+ const finalStyle = {
+ ...style,
+ right: 2,
+ bottom: 2,
+ top: 2,
+ borderRadius: 3,
+ width: SCROLLBAR_SIZE
+ };
+
+ 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..4dbd395cd
--- /dev/null
+++ b/frontend/src/Components/Scroller/Scroller.css
@@ -0,0 +1,37 @@
+.scroller {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+ -webkit-overflow-scrolling: touch;
+}
+
+.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;
+ }
+}
+
+.both {
+ overflow: scroll;
+
+ &.autoScroll {
+ overflow: auto;
+ }
+}
diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js
new file mode 100644
index 000000000..f4ce7781f
--- /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.all).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..86930b489
--- /dev/null
+++ b/frontend/src/Components/SignalRConnector.js
@@ -0,0 +1,398 @@
+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 { fetchArtist } from 'Store/Actions/artistActions';
+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,
+ dispatchFetchArtist: fetchArtist,
+ 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', 'serverSentEvents', 'longPolling'] };
+ this.signalRconnection = null;
+ this.retryInterval = 1;
+ this.retryTimeoutId = null;
+ this.disconnectedTime = null;
+ }
+
+ componentDidMount() {
+ console.log('Starting signalR');
+
+ const url = `${window.Lidarr.urlBase}/signalr`;
+
+ this.signalRconnection = $.connection(url, { apiKey: window.Lidarr.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);
+ }
+ }
+
+ handleAlbum = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'albums',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleTrack = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'tracks',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleTrackfile = (body) => {
+ const section = 'trackFiles';
+
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...body.resource });
+ } else if (body.action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: body.resource.id });
+ }
+
+ // Repopulate the page to handle recently imported file
+ repopulatePage('trackFileUpdated');
+ }
+
+ handleHealth = () => {
+ this.props.dispatchFetchHealth();
+ }
+
+ handleArtist = (body) => {
+ const action = body.action;
+ const section = 'artist';
+
+ if (action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...body.resource });
+ } else if (action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: body.resource.id });
+ }
+ }
+
+ handleQueue = () => {
+ if (this.props.isQueuePopulated) {
+ this.props.dispatchFetchQueue();
+ }
+ }
+
+ handleQueueDetails = () => {
+ this.props.dispatchFetchQueueDetails();
+ }
+
+ handleQueueStatus = (body) => {
+ this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
+ }
+
+ handleVersion = (body) => {
+ const version = body.Version;
+
+ this.props.dispatchSetVersion({ version });
+ }
+
+ handleWantedCutoff = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'cutoffUnmet',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleWantedMissing = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'missing',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleSystemTask = () => {
+ // No-op for now, we may want this later
+ }
+
+ handleRootfolder = () => {
+ this.props.dispatchFetchRootFolders();
+ }
+
+ 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,
+ dispatchFetchArtist,
+ 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) {
+ dispatchFetchArtist();
+ 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.Lidarr.unloading) {
+ return;
+ }
+
+ if (!this.disconnectedTime) {
+ this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
+ }
+
+ this.props.dispatchSetAppValue({
+ isReconnecting: true
+ });
+ }
+
+ onDisconnected = () => {
+ if (window.Lidarr.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,
+ dispatchFetchArtist: 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/StarRating.css b/frontend/src/Components/StarRating.css
new file mode 100644
index 000000000..da7d9c79c
--- /dev/null
+++ b/frontend/src/Components/StarRating.css
@@ -0,0 +1,19 @@
+.starRating {
+ display: flex;
+ align-items: left;
+ justify-content: left;
+}
+
+.backStar {
+ position: relative;
+ display: flex;
+ color: #515253;
+}
+
+.frontStar {
+ position: absolute;
+ top: 0;
+ display: flex;
+ overflow: hidden;
+ color: #ffbc0b;
+}
diff --git a/frontend/src/Components/StarRating.js b/frontend/src/Components/StarRating.js
new file mode 100644
index 000000000..f895345b4
--- /dev/null
+++ b/frontend/src/Components/StarRating.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import styles from './StarRating.css';
+
+function StarRating({ rating, votes, iconSize }) {
+ const starWidth = {
+ width: `${rating * 10}%`
+ };
+
+ const helpText = `${rating/2} (${votes} Votes)`;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+StarRating.propTypes = {
+ rating: PropTypes.number.isRequired,
+ votes: PropTypes.number.isRequired,
+ iconSize: PropTypes.number.isRequired
+};
+
+StarRating.defaultProps = {
+ iconSize: 14
+};
+
+export default StarRating;
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css
new file mode 100644
index 000000000..e96e5cc10
--- /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..207b97752
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js
@@ -0,0 +1,66 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import TableRowCell from './TableRowCell';
+import styles from './RelativeDateCell.css';
+
+class RelativeDateCell extends PureComponent {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ date,
+ includeSeconds,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ component: Component,
+ dispatch,
+ ...otherProps
+ } = this.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.elementType,
+ 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..c695d42fc
--- /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..be087c702
--- /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..2501b7c84
--- /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..ec7c61b92
--- /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..bdfdec641
--- /dev/null
+++ b/frontend/src/Components/Table/Table.css
@@ -0,0 +1,23 @@
+.tableContainer {
+ &.horizontalScroll {
+ overflow-x: auto;
+ }
+}
+
+.table {
+ max-width: 100%;
+ width: 100%;
+ border-collapse: collapse;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .tableContainer {
+ min-width: 100%;
+ width: fit-content;
+
+ &.horizontalScroll {
+ 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..dbd60bf5f
--- /dev/null
+++ b/frontend/src/Components/Table/Table.js
@@ -0,0 +1,141 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+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,
+ horizontalScroll,
+ selectAll,
+ columns,
+ optionsComponent,
+ pageSize,
+ canModifyColumns,
+ children,
+ onSortPress,
+ onTableOptionChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ {
+ selectAll ?
+ :
+ null
+ }
+
+ {
+ 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,
+ horizontalScroll: PropTypes.bool.isRequired,
+ selectAll: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ optionsComponent: PropTypes.elementType,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func,
+ onTableOptionChange: PropTypes.func
+};
+
+Table.defaultProps = {
+ className: styles.table,
+ horizontalScroll: true,
+ 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..2a36668fe
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
@@ -0,0 +1,260 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DndProvider } 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 (
+
+
+ {
+ isOpen ?
+
+
+ Table Options
+
+
+
+
+
+
+
+ Close
+
+
+ :
+ null
+ }
+
+
+ );
+ }
+}
+
+TableOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool.isRequired,
+ optionsComponent: PropTypes.elementType,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+TableOptionsModal.defaultProps = {
+ canModifyColumns: true
+};
+
+export default 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..19f5a8f6b
--- /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..e51ca44a4
--- /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..9b6f6e622
--- /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..258d31b00
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { WindowScroller } from 'react-virtualized';
+import { isLocked } from 'Utilities/scrollLock';
+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;
+ }
+ }
+
+ onScroll = (props) => {
+ if (isLocked()) {
+ return;
+ }
+
+ const { onScroll } = this.props;
+
+ onScroll(props);
+ }
+
+ //
+ // 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..7790ae17d
--- /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..7b0592844
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.css
@@ -0,0 +1,15 @@
+.title {
+ padding: 10px 20px;
+ border-bottom: 1px solid $popoverTitleBorderColor;
+ background-color: $popoverTitleBackgroundColor;
+ font-size: 16px;
+}
+
+.body {
+ overflow: auto;
+ padding: 10px;
+}
+
+.tooltipBody {
+ padding: 0;
+}
diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js
new file mode 100644
index 000000000..9ce73cf08
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Tooltip from './Tooltip';
+import styles from './Popover.css';
+
+function Popover(props) {
+ const {
+ title,
+ body,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {title}
+
+
+
+ {body}
+
+
+ }
+ />
+ );
+}
+
+Popover.propTypes = {
+ title: PropTypes.string.isRequired,
+ body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired
+};
+
+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..1db58372b
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.css
@@ -0,0 +1,158 @@
+.tooltipContainer {
+ z-index: $popperZIndex;
+ 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..3f8b5ad06
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.js
@@ -0,0 +1,206 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Manager, Popper, Reference } from 'react-popper';
+import classNames from 'classnames';
+import { isMobile as isMobileUtil } from 'Utilities/mobile';
+import { kinds, tooltipPositions } from 'Helpers/Props';
+import Portal from 'Components/Portal';
+import styles from './Tooltip.css';
+
+class Tooltip extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scheduleUpdate = null;
+ this._closeTimeout = null;
+
+ this.state = {
+ isOpen: false
+ };
+ }
+
+ componentDidUpdate() {
+ if (this._scheduleUpdate && this.state.isOpen) {
+ this._scheduleUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._closeTimeout) {
+ this._closeTimeout = clearTimeout(this._closeTimeout);
+ }
+ }
+
+ //
+ // Control
+
+ computeMaxSize = (data) => {
+ const {
+ top,
+ right,
+ bottom,
+ left
+ } = data.offsets.reference;
+
+ const windowWidth = window.innerWidth;
+ const windowHeight = window.innerHeight;
+
+ if ((/^top/).test(data.placement)) {
+ data.styles.maxHeight = top - 20;
+ } else if ((/^bottom/).test(data.placement)) {
+ data.styles.maxHeight = windowHeight - bottom - 20;
+ } else if ((/^right/).test(data.placement)) {
+ data.styles.maxWidth = windowWidth - right - 30;
+ } else {
+ data.styles.maxWidth = left - 30;
+ }
+
+ return data;
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ }
+
+ 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,
+ bodyClassName,
+ anchor,
+ tooltip,
+ kind,
+ position,
+ canFlip
+ } = this.props;
+
+ return (
+
+
+ {({ ref }) => (
+
+ {anchor}
+
+ )}
+
+
+
+
+ {({ ref, style, placement, scheduleUpdate }) => {
+ this._scheduleUpdate = scheduleUpdate;
+
+ return (
+
+ {
+ this.state.isOpen ?
+
:
+ null
+ }
+
+ );
+ }}
+
+
+
+ );
+ }
+}
+
+Tooltip.propTypes = {
+ className: PropTypes.string,
+ bodyClassName: PropTypes.string.isRequired,
+ anchor: PropTypes.node.isRequired,
+ tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
+ position: PropTypes.oneOf(tooltipPositions.all),
+ canFlip: PropTypes.bool.isRequired
+};
+
+Tooltip.defaultProps = {
+ bodyClassName: styles.body,
+ kind: kinds.DEFAULT,
+ position: tooltipPositions.TOP,
+ canFlip: true
+};
+
+export default Tooltip;
diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js
new file mode 100644
index 000000000..eb0d7c1d7
--- /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'
+ },
+
+ ARTIST_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/src/UI/Content/fonts/ubuntumono-regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot
similarity index 100%
rename from src/UI/Content/fonts/ubuntumono-regular.eot
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.eot
diff --git a/src/UI/Content/fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
similarity index 100%
rename from src/UI/Content/fonts/UbuntuMono-Regular.ttf
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
diff --git a/src/UI/Content/fonts/ubuntumono-regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff
similarity index 100%
rename from src/UI/Content/fonts/ubuntumono-regular.woff
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.woff
diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css
new file mode 100644
index 000000000..bf31501dd
--- /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.3.0') format('woff2'), url('Roboto-Light.woff?v=1.3.0') format('woff'), url('Roboto-Light.ttf?v=1.3.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.3.0') format('woff'), url('Roboto-Regular.ttf?v=1.3.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.3.0') format('woff'), url('Roboto-Regular.ttf?v=1.3.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Ubuntu Mono';
+ src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype');
+}
+
+/*
+ * text-security-disc
+ */
+
+@font-face {
+ font-weight: normal;
+ font-style: normal;
+ font-family: 'text-security-disc';
+ src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') 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..6e5a37b78
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..88a584f88
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..859cdc3c8
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..14aed1616
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..eb2a9cf70
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..242d170fb
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-debug-16x16.png b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png
new file mode 100644
index 000000000..6031bc849
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-debug-32x32.png b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png
new file mode 100644
index 000000000..363966ffa
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-debug.ico b/frontend/src/Content/Images/Icons/favicon-debug.ico
new file mode 100644
index 000000000..726e812c6
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug.ico differ
diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico
new file mode 100644
index 000000000..1b0de8423
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..1ffe2e9c5
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..1008bf9a0
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..340abd98e
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..6e62ce87f
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..ecbb0dd58
Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/safari-pinned-tab.svg b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg
new file mode 100644
index 000000000..6fc7fb969
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg
@@ -0,0 +1,38 @@
+
+
+
+
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+
+
+
+
+
diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png
new file mode 100644
index 000000000..9b1ae7746
Binary files /dev/null and b/frontend/src/Content/Images/error.png differ
diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg
new file mode 100644
index 000000000..ebebe49a9
--- /dev/null
+++ b/frontend/src/Content/Images/logo.svg
@@ -0,0 +1 @@
+ background Layer 1
\ No newline at end of file
diff --git a/frontend/src/Content/Images/poster-dark-square.png b/frontend/src/Content/Images/poster-dark-square.png
new file mode 100644
index 000000000..efadba25e
Binary files /dev/null and b/frontend/src/Content/Images/poster-dark-square.png differ
diff --git a/frontend/src/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png
new file mode 100644
index 000000000..0b5c9786a
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/Shapes/tagShape.js b/frontend/src/Helpers/Props/Shapes/tagShape.js
new file mode 100644
index 000000000..d701f4e8a
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/tagShape.js
@@ -0,0 +1,8 @@
+import PropTypes from 'prop-types';
+
+const tagShape = {
+ id: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]).isRequired,
+ name: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired
+};
+
+export default tagShape;
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..42df49eda
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
@@ -0,0 +1,11 @@
+export const BOOL = 'bool';
+export const BYTES = 'bytes';
+export const DATE = 'date';
+export const DEFAULT = 'default';
+export const INDEXER = 'indexer';
+export const METADATA_PROFILE = 'metadataProfile';
+export const PROTOCOL = 'protocol';
+export const QUALITY = 'quality';
+export const QUALITY_PROFILE = 'qualityProfile';
+export const ARTIST_STATUS = 'artistStatus';
+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..86ea9c58b
--- /dev/null
+++ b/frontend/src/Helpers/Props/icons.js
@@ -0,0 +1,215 @@
+//
+// 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,
+ faFileAudio as farFileAudio,
+ 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,
+ faArrowCircleUp as fasArrowCircleUp,
+ faLongArrowAltRight as fasLongArrowAltRight,
+ faBackward as fasBackward,
+ faBan as fasBan,
+ 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,
+ faEdit as fasEdit,
+ faEllipsisH as fasEllipsisH,
+ faExclamationCircle as fasExclamationCircle,
+ faExclamationTriangle as fasExclamationTriangle,
+ faExternalLinkAlt as fasExternalLinkAlt,
+ faEye as fasEye,
+ faFastBackward as fasFastBackward,
+ faFastForward as fasFastForward,
+ faFileImport as fasFileImport,
+ faFileInvoice as farFileInvoice,
+ faFilter as fasFilter,
+ faFolderOpen as fasFolderOpen,
+ faForward as fasForward,
+ faHeart as fasHeart,
+ faHistory as fasHistory,
+ faHome as fasHome,
+ faInfoCircle as fasInfoCircle,
+ faLaptop as fasLaptop,
+ faLevelUpAlt as fasLevelUpAlt,
+ faMedkit as fasMedkit,
+ faMinus as fasMinus,
+ faPause as fasPause,
+ faPlay as fasPlay,
+ faPlus as fasPlus,
+ faPowerOff as fasPowerOff,
+ faQuestion as fasQuestion,
+ faQuestionCircle as fasQuestionCircle,
+ faRedoAlt as fasRedoAlt,
+ faRetweet as fasRetweet,
+ faRss as fasRss,
+ faRocket as fasRocket,
+ faSave as fasSave,
+ faSearch as fasSearch,
+ faSignOutAlt as fasSignOutAlt,
+ faSitemap as fasSitemap,
+ faSpinner as fasSpinner,
+ faSort as fasSort,
+ faSortDown as fasSortDown,
+ faSortUp as fasSortUp,
+ faStar as fasStar,
+ 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 ARROW_RIGHT_NO_CIRCLE = fasLongArrowAltRight;
+export const ARROW_UP = fasArrowCircleUp;
+export const BACKUP = farFileArchive;
+export const BAN = fasBan;
+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 TRACK_FILE = farFileAudio;
+export const EXPAND = fasChevronCircleDown;
+export const EXPAND_INDETERMINATE = fasChevronCircleRight;
+export const EXTERNAL_LINK = fasExternalLinkAlt;
+export const FATAL = fasTimesCircle;
+export const FILE = farFile;
+export const FILEIMPORT = fasFileImport;
+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 REORDER = fasBars;
+export const RESTART = fasRedoAlt;
+export const RESTORE = fasHistory;
+export const RETAG = fasEdit;
+export const RSS = fasRss;
+export const SAVE = fasSave;
+export const SCHEDULED = farClock;
+export const SCORE = fasUserPlus;
+export const SEARCH = fasSearch;
+export const ARTIST_CONTINUING = fasPlay;
+export const ARTIST_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 STAR_FULL = fasStar;
+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..172ca331c
--- /dev/null
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -0,0 +1,43 @@
+export const AUTO_COMPLETE = 'autoComplete';
+export const CAPTCHA = 'captcha';
+export const CHECK = 'check';
+export const DEVICE = 'device';
+export const PLAYLIST = 'playlist';
+export const KEY_VALUE_LIST = 'keyValueList';
+export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect';
+export const NUMBER = 'number';
+export const OAUTH = 'oauth';
+export const PASSWORD = 'password';
+export const PATH = 'path';
+export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
+export const METADATA_PROFILE_SELECT = 'metadataProfileSelect';
+export const ALBUM_RELEASE_SELECT = 'albumReleaseSelect';
+export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
+export const SELECT = 'select';
+export const SERIES_TYPE_SELECT = 'artistTypeSelect';
+export const TAG = 'tag';
+export const TEXT = 'text';
+export const TEXT_TAG = 'textTag';
+
+export const all = [
+ AUTO_COMPLETE,
+ CAPTCHA,
+ CHECK,
+ DEVICE,
+ PLAYLIST,
+ KEY_VALUE_LIST,
+ MONITOR_ALBUMS_SELECT,
+ NUMBER,
+ OAUTH,
+ PASSWORD,
+ PATH,
+ QUALITY_PROFILE_SELECT,
+ METADATA_PROFILE_SELECT,
+ ALBUM_RELEASE_SELECT,
+ ROOT_FOLDER_SELECT,
+ SELECT,
+ SERIES_TYPE_SELECT,
+ TAG,
+ TEXT,
+ TEXT_TAG
+];
diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js
new file mode 100644
index 000000000..fd2c17f7b
--- /dev/null
+++ b/frontend/src/Helpers/Props/kinds.js
@@ -0,0 +1,23 @@
+export const DANGER = 'danger';
+export const DEFAULT = 'default';
+export const DISABLED = 'disabled';
+export const INFO = 'info';
+export const INVERSE = 'inverse';
+export const PINK = 'pink';
+export const PRIMARY = 'primary';
+export const PURPLE = 'purple';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ DANGER,
+ DEFAULT,
+ DISABLED,
+ INFO,
+ INVERSE,
+ PINK,
+ PRIMARY,
+ PURPLE,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js
new file mode 100644
index 000000000..997354f9d
--- /dev/null
+++ b/frontend/src/Helpers/Props/messageTypes.js
@@ -0,0 +1,11 @@
+export const ERROR = 'error';
+export const INFO = 'info';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ ERROR,
+ INFO,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js
new file mode 100644
index 000000000..1ae61143b
--- /dev/null
+++ b/frontend/src/Helpers/Props/scrollDirections.js
@@ -0,0 +1,6 @@
+export const NONE = 'none';
+export const BOTH = 'both';
+export const HORIZONTAL = 'horizontal';
+export const VERTICAL = 'vertical';
+
+export const all = [NONE, HORIZONTAL, VERTICAL, BOTH];
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/Album/SelectAlbumModal.js b/frontend/src/InteractiveImport/Album/SelectAlbumModal.js
new file mode 100644
index 000000000..d4f26f4ff
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectAlbumModalContentConnector from './SelectAlbumModalContentConnector';
+
+class SelectAlbumModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectAlbumModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectAlbumModal;
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.css b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.css
new file mode 100644
index 000000000..54f67bb07
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.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/Album/SelectAlbumModalContent.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js
new file mode 100644
index 000000000..20115214e
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js
@@ -0,0 +1,141 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import { scrollDirections } from 'Helpers/Props';
+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 Scroller from 'Components/Scroller/Scroller';
+import TextInput from 'Components/Form/TextInput';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectAlbumRow from './SelectAlbumRow';
+import styles from './SelectAlbumModalContent.css';
+
+const columns = [
+ {
+ name: 'title',
+ label: 'Album Title',
+ isVisible: true
+ },
+ {
+ name: 'albumType',
+ label: 'Album Type',
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: 'Release Date',
+ isVisible: true
+ },
+ {
+ name: 'status',
+ label: 'Album Status',
+ isVisible: true
+ }
+];
+
+class SelectAlbumModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ filter: ''
+ };
+ }
+
+ //
+ // Listeners
+
+ onFilterChange = ({ value }) => {
+ this.setState({ filter: value.toLowerCase() });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onAlbumSelect,
+ onModalClose,
+ isFetching,
+ ...otherProps
+ } = this.props;
+
+ const filter = this.state.filter;
+
+ return (
+
+
+ Manual Import - Select Album
+
+
+
+ {
+ isFetching &&
+
+ }
+
+
+
+ {
+
+
+ {
+ items.map((item) => {
+ return item.title.toLowerCase().includes(filter) ?
+ (
+
+ ) :
+ null;
+ })
+ }
+
+
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+SelectAlbumModalContent.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ onAlbumSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectAlbumModalContent;
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js
new file mode 100644
index 000000000..6302df334
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js
@@ -0,0 +1,104 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import {
+ updateInteractiveImportItem,
+ saveInteractiveImportItem,
+ fetchInteractiveImportAlbums,
+ setInteractiveImportAlbumsSort,
+ clearInteractiveImportAlbums
+} from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import SelectAlbumModalContent from './SelectAlbumModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('interactiveImport.albums'),
+ (albums) => {
+ return albums;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportAlbums,
+ setInteractiveImportAlbumsSort,
+ clearInteractiveImportAlbums,
+ updateInteractiveImportItem,
+ saveInteractiveImportItem
+};
+
+class SelectAlbumModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId
+ } = this.props;
+
+ this.props.fetchInteractiveImportAlbums({ artistId });
+ }
+
+ componentWillUnmount() {
+ // This clears the albums for the queue and hides the queue
+ // We'll need another place to store albums for manual import
+ this.props.clearInteractiveImportAlbums();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection });
+ }
+
+ onAlbumSelect = (albumId) => {
+ const album = _.find(this.props.items, { id: albumId });
+
+ const ids = this.props.ids;
+
+ ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ album,
+ albumReleaseId: undefined,
+ tracks: [],
+ rejections: []
+ });
+ });
+
+ this.props.saveInteractiveImportItem({ id: ids });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectAlbumModalContentConnector.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.number).isRequired,
+ artistId: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchInteractiveImportAlbums: PropTypes.func.isRequired,
+ setInteractiveImportAlbumsSort: PropTypes.func.isRequired,
+ clearInteractiveImportAlbums: PropTypes.func.isRequired,
+ saveInteractiveImportItem: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectAlbumModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumRow.css b/frontend/src/InteractiveImport/Album/SelectAlbumRow.css
new file mode 100644
index 000000000..e78f0bc19
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.css
@@ -0,0 +1,3 @@
+.albumRow {
+ cursor: pointer;
+}
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumRow.js b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js
new file mode 100644
index 000000000..d3f69b057
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js
@@ -0,0 +1,140 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import Label from 'Components/Label';
+import styles from './SelectAlbumRow.css';
+
+function getTrackCountKind(monitored, trackFileCount, trackCount) {
+ if (trackFileCount === trackCount && trackCount > 0) {
+ return kinds.SUCCESS;
+ }
+
+ if (!monitored) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+class SelectAlbumRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onAlbumSelect(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ title,
+ disambiguation,
+ albumType,
+ releaseDate,
+ statistics,
+ monitored,
+ columns
+ } = this.props;
+
+ const {
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ } = statistics;
+
+ const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'title') {
+ return (
+
+ {extendedTitle}
+
+ );
+ }
+
+ if (name === 'albumType') {
+ return (
+
+ {albumType}
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+ {
+ {trackFileCount} / {trackCount}
+ }
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+ );
+ }
+}
+
+SelectAlbumRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ onAlbumSelect: PropTypes.func.isRequired,
+ statistics: PropTypes.object.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+SelectAlbumRow.defaultProps = {
+ statistics: {
+ trackCount: 0,
+ trackFileCount: 0
+ }
+};
+
+export default SelectAlbumRow;
diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js
new file mode 100644
index 000000000..f3789d9dd
--- /dev/null
+++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectAlbumReleaseModalContentConnector from './SelectAlbumReleaseModalContentConnector';
+
+class SelectAlbumReleaseModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectAlbumReleaseModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectAlbumReleaseModal;
diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css
new file mode 100644
index 000000000..54f67bb07
--- /dev/null
+++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.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/AlbumRelease/SelectAlbumReleaseModalContent.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js
new file mode 100644
index 000000000..5c87f982e
--- /dev/null
+++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import { scrollDirections } from 'Helpers/Props';
+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 SelectAlbumReleaseRow from './SelectAlbumReleaseRow';
+import Alert from 'Components/Alert';
+import styles from './SelectAlbumReleaseModalContent.css';
+
+const columns = [
+ {
+ name: 'album',
+ label: 'Album',
+ isVisible: true
+ },
+ {
+ name: 'release',
+ label: 'Album Release',
+ isVisible: true
+ }
+];
+
+class SelectAlbumReleaseModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ albums,
+ onAlbumReleaseSelect,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ Manual Import - Select Album Release
+
+
+
+
+ Overrriding a release here will disable automatic release selection for that album in future.
+
+
+
+
+ {
+ albums.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+SelectAlbumReleaseModalContent.propTypes = {
+ albums: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onAlbumReleaseSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectAlbumReleaseModalContent;
diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js
new file mode 100644
index 000000000..f308b03ce
--- /dev/null
+++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import {
+ updateInteractiveImportItem,
+ saveInteractiveImportItem
+} from 'Store/Actions/interactiveImportActions';
+import SelectAlbumReleaseModalContent from './SelectAlbumReleaseModalContent';
+
+function createMapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ updateInteractiveImportItem,
+ saveInteractiveImportItem
+};
+
+class SelectAlbumReleaseModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ // onSortPress = (sortKey, sortDirection) => {
+ // this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection });
+ // }
+
+ onAlbumReleaseSelect = (albumId, albumReleaseId) => {
+ const ids = this.props.importIdsByAlbum[albumId];
+
+ ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ albumReleaseId,
+ disableReleaseSwitching: true,
+ tracks: [],
+ rejections: []
+ });
+ });
+
+ this.props.saveInteractiveImportItem({ id: ids });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectAlbumReleaseModalContentConnector.propTypes = {
+ importIdsByAlbum: PropTypes.object.isRequired,
+ albums: PropTypes.arrayOf(PropTypes.object).isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ saveInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectAlbumReleaseModalContentConnector);
diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css
new file mode 100644
index 000000000..e78f0bc19
--- /dev/null
+++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css
@@ -0,0 +1,3 @@
+.albumRow {
+ cursor: pointer;
+}
diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js
new file mode 100644
index 000000000..786ea0f83
--- /dev/null
+++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js
@@ -0,0 +1,96 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import titleCase from 'Utilities/String/titleCase';
+
+class SelectAlbumReleaseRow extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.onAlbumReleaseSelect(parseInt(name), parseInt(value));
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ matchedReleaseId,
+ title,
+ disambiguation,
+ releases,
+ columns
+ } = this.props;
+
+ const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'album') {
+ return (
+
+ {extendedTitle}
+
+ );
+ }
+
+ if (name === 'release') {
+ return (
+
+ ({
+ key: r.id,
+ value: `${r.title}` +
+ `${r.disambiguation ? ' (' : ''}${titleCase(r.disambiguation)}${r.disambiguation ? ')' : ''}` +
+ `, ${r.mediumCount} med, ${r.trackCount} tracks` +
+ `${r.country.length > 0 ? ', ' : ''}${r.country}` +
+ `${r.format ? ', [' : ''}${r.format}${r.format ? ']' : ''}` +
+ `${r.monitored ? ', Monitored' : ''}`
+ }))}
+ value={matchedReleaseId}
+ onChange={this.onInputChange}
+ />
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+ );
+ }
+}
+
+SelectAlbumReleaseRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ matchedReleaseId: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string.isRequired,
+ releases: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onAlbumReleaseSelect: PropTypes.func.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default SelectAlbumReleaseRow;
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModal.js b/frontend/src/InteractiveImport/Artist/SelectArtistModal.js
new file mode 100644
index 000000000..39dd67300
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectArtistModalContentConnector from './SelectArtistModalContentConnector';
+
+class SelectArtistModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectArtistModal;
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css
new file mode 100644
index 000000000..54f67bb07
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.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/Artist/SelectArtistModalContent.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js
new file mode 100644
index 000000000..b180d319b
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.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 SelectArtistRow from './SelectArtistRow';
+import styles from './SelectArtistModalContent.css';
+
+class SelectArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ filter: ''
+ };
+ }
+
+ //
+ // Listeners
+
+ onFilterChange = ({ value }) => {
+ this.setState({ filter: value.toLowerCase() });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onArtistSelect,
+ onModalClose
+ } = this.props;
+
+ const filter = this.state.filter;
+
+ return (
+
+
+ Manual Import - Select Artist
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return item.artistName.toLowerCase().includes(filter) ?
+ (
+
+ ) :
+ null;
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+SelectArtistModalContent.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onArtistSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectArtistModalContent;
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js
new file mode 100644
index 000000000..19a6002c9
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js
@@ -0,0 +1,83 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { updateInteractiveImportItem, saveInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import SelectArtistModalContent from './SelectArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createAllArtistSelector(),
+ (items) => {
+ return {
+ items: items.sort((a, b) => {
+ if (a.sortName < b.sortName) {
+ return -1;
+ }
+
+ if (a.sortName > b.sortName) {
+ return 1;
+ }
+
+ return 0;
+ })
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateInteractiveImportItem,
+ saveInteractiveImportItem
+};
+
+class SelectArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onArtistSelect = (artistId) => {
+ const artist = _.find(this.props.items, { id: artistId });
+
+ const ids = this.props.ids;
+
+ ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ artist,
+ album: undefined,
+ albumReleaseId: undefined,
+ tracks: [],
+ rejections: []
+ });
+ });
+
+ this.props.saveInteractiveImportItem({ id: ids });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectArtistModalContentConnector.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.number).isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ saveInteractiveImportItem: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectArtistModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistRow.css b/frontend/src/InteractiveImport/Artist/SelectArtistRow.css
new file mode 100644
index 000000000..376c3fe84
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistRow.css
@@ -0,0 +1,4 @@
+.artist {
+ padding: 8px;
+ border-bottom: 1px solid $borderColor;
+}
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistRow.js b/frontend/src/InteractiveImport/Artist/SelectArtistRow.js
new file mode 100644
index 000000000..dcf252bb6
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistRow.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './SelectArtistRow.css';
+
+class SelectArtistRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onArtistSelect(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ {this.props.artistName}
+
+ );
+ }
+}
+
+SelectArtistRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ onArtistSelect: PropTypes.func.isRequired
+};
+
+export default SelectArtistRow;
diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js
new file mode 100644
index 000000000..e002b2de9
--- /dev/null
+++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import ConfirmImportModalContentConnector from './ConfirmImportModalContentConnector';
+
+class ConfirmImportModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+ConfirmImportModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ConfirmImportModal;
diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js
new file mode 100644
index 000000000..c91aa333b
--- /dev/null
+++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js
@@ -0,0 +1,135 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import { kinds } from 'Helpers/Props';
+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 Alert from 'Components/Alert';
+
+function formatAlbumFiles(items, album) {
+
+ return (
+
+
{album.title}
+
+ {
+ _.sortBy(items, 'path').map((item) => {
+ return (
+
+ {item.path}
+
+ );
+ })
+ }
+
+
+ );
+
+}
+
+class ConfirmImportModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ isFetching,
+ isPopulated
+ } = this.props;
+
+ if (!isFetching && isPopulated && !items.length) {
+ this.props.onModalClose();
+ this.props.onConfirmImportPress();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ albums,
+ items,
+ onConfirmImportPress,
+ onModalClose,
+ isFetching,
+ isPopulated
+ } = this.props;
+
+ // don't render if nothing to do
+ if (!isFetching && isPopulated && !items.length) {
+ return null;
+ }
+
+ return (
+
+
+ {
+ !isFetching && isPopulated &&
+
+ Are you sure?
+
+ }
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && isPopulated &&
+
+
+ You already have files imported for the albums listed below. If you continue, the existing files will be deleted and the new files imported in their place.
+
+ To avoid deleting existing files, press 'Cancel' and use the 'Combine with existing files' option.
+
+
+ { _.chain(items)
+ .groupBy('albumId')
+ .mapValues((value, key) => formatAlbumFiles(value, _.find(albums, (a) => a.id === parseInt(key))))
+ .values()
+ .value() }
+
+ }
+
+
+ {
+ !isFetching && isPopulated &&
+
+
+ Cancel
+
+
+
+ Proceed
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ConfirmImportModalContent.propTypes = {
+ albums: PropTypes.arrayOf(PropTypes.object).isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ onConfirmImportPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ConfirmImportModalContent;
diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js
new file mode 100644
index 000000000..dab76fb33
--- /dev/null
+++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchInteractiveImportTrackFiles, clearInteractiveImportTrackFiles } from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import ConfirmImportModalContent from './ConfirmImportModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('interactiveImport.trackFiles'),
+ (trackFiles) => {
+ return trackFiles;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportTrackFiles,
+ clearInteractiveImportTrackFiles
+};
+
+class ConfirmImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ albums
+ } = this.props;
+
+ this.props.fetchInteractiveImportTrackFiles({ albumId: albums.map((x) => x.id) });
+ }
+
+ componentWillUnmount() {
+ this.props.clearInteractiveImportTrackFiles();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ConfirmImportModalContentConnector.propTypes = {
+ albums: PropTypes.arrayOf(PropTypes.object).isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchInteractiveImportTrackFiles: PropTypes.func.isRequired,
+ clearInteractiveImportTrackFiles: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ConfirmImportModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css
new file mode 100644
index 000000000..5f9033a18
--- /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..8a6c58fb0
--- /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_ALBUMS_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..58eb9a8e4
--- /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..d50f3a261
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
@@ -0,0 +1,65 @@
+.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,
+.rightButtons {
+ display: flex;
+ flex: 1 0 50%;
+ flex-wrap: wrap;
+}
+
+.rightButtons {
+ justify-content: flex-end;
+}
+
+.importMode,
+.bulkSelect {
+ composes: select from '~Components/Form/SelectInput.css';
+
+ margin-right: 10px;
+ width: auto;
+}
+
+.errorMessage {
+ color: $dangerColor;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .footer {
+ .leftButtons,
+ .rightButtons {
+ flex-direction: column;
+ }
+
+ .leftButtons {
+ align-items: flex-start;
+ }
+
+ .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..1edac2b7c
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js
@@ -0,0 +1,571 @@
+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, scrollDirections } 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 SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
+import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal';
+import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal';
+import SelectAlbumReleaseModal from 'InteractiveImport/AlbumRelease/SelectAlbumReleaseModal';
+import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal';
+import InteractiveImportRow from './InteractiveImportRow';
+import styles from './InteractiveImportModalContent.css';
+
+const columns = [
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'artist',
+ label: 'Artist',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'album',
+ label: 'Album',
+ isVisible: true
+ },
+ {
+ name: 'tracks',
+ label: 'Track(s)',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, {
+ name: icons.DANGER,
+ kind: kinds.DANGER
+ }),
+ isVisible: true
+ }
+];
+
+const filterExistingFilesOptions = {
+ ALL: 'all',
+ NEW: 'new'
+};
+
+const importModeOptions = [
+ { key: 'move', value: 'Move Files' },
+ { key: 'copy', value: 'Hardlink/Copy Files' }
+];
+
+const SELECT = 'select';
+const ARTIST = 'artist';
+const ALBUM = 'album';
+const ALBUM_RELEASE = 'albumRelease';
+const QUALITY = 'quality';
+
+const replaceExistingFilesOptions = {
+ COMBINE: 'combine',
+ DELETE: 'delete'
+};
+
+class InteractiveImportModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ invalidRowsSelected: [],
+ selectModalOpen: null,
+ albumsImported: [],
+ isConfirmImportModalOpen: false,
+ showClearTracks: false,
+ inconsistentAlbumReleases: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const selectedIds = this.getSelectedIds();
+ const selectedItems = _.filter(this.props.items, (x) => _.includes(selectedIds, x.id));
+ const selectionHasTracks = _.some(selectedItems, (x) => x.tracks.length);
+
+ if (this.state.showClearTracks !== selectionHasTracks) {
+ this.setState({ showClearTracks: selectionHasTracks });
+ }
+
+ const inconsistent = _(selectedItems)
+ .map((x) => ({ albumId: x.album ? x.album.id : 0, releaseId: x.albumReleaseId }))
+ .groupBy('albumId')
+ .mapValues((album) => _(album).groupBy((x) => x.releaseId).values().value().length)
+ .values()
+ .some((x) => x !== undefined && x > 1);
+
+ if (inconsistent !== this.state.inconsistentAlbumReleases) {
+ this.setState({ inconsistentAlbumReleases: inconsistent });
+ }
+ }
+
+ //
+ // 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, props) => {
+ // make sure to exclude any invalidRows that are no longer present in props
+ const diff = _.difference(state.invalidRowsSelected, _.map(props.items, 'id'));
+ const currentInvalid = _.difference(state.invalidRowsSelected, diff);
+ const newstate = isValid ? _.without(currentInvalid, id) : _.union(currentInvalid, [id]);
+ return { invalidRowsSelected: newstate };
+ });
+ }
+
+ onImportSelectedPress = () => {
+ if (!this.props.replaceExistingFiles) {
+ this.onConfirmImportPress();
+ return;
+ }
+
+ // potentially deleting files
+ const selectedIds = this.getSelectedIds();
+ const albumsImported = _(this.props.items)
+ .filter((x) => _.includes(selectedIds, x.id))
+ .keyBy((x) => x.album.id)
+ .map((x) => x.album)
+ .value();
+
+ console.log(albumsImported);
+
+ this.setState({
+ albumsImported,
+ isConfirmImportModalOpen: true
+ });
+ }
+
+ onConfirmImportPress = () => {
+ const {
+ downloadId,
+ showImportMode,
+ importMode,
+ onImportSelectedPress
+ } = this.props;
+
+ const selected = this.getSelectedIds();
+ const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode;
+
+ onImportSelectedPress(selected, finalImportMode);
+ }
+
+ onFilterExistingFilesChange = (value) => {
+ this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL);
+ }
+
+ onReplaceExistingFilesChange = (value) => {
+ this.props.onReplaceExistingFilesChange(value === replaceExistingFilesOptions.DELETE);
+ }
+
+ onImportModeChange = ({ value }) => {
+ this.props.onImportModeChange(value);
+ }
+
+ onSelectModalSelect = ({ value }) => {
+ this.setState({ selectModalOpen: value });
+ }
+
+ onClearTrackMappingPress = () => {
+ const selectedIds = this.getSelectedIds();
+
+ selectedIds.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ tracks: [],
+ rejections: []
+ });
+ });
+ }
+
+ onGetTrackMappingPress = () => {
+ this.props.saveInteractiveImportItem({ id: this.getSelectedIds() });
+ }
+
+ onSelectModalClose = () => {
+ this.setState({ selectModalOpen: null });
+ }
+
+ onConfirmImportModalClose = () => {
+ this.setState({ isConfirmImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ downloadId,
+ allowArtistChange,
+ showFilterExistingFiles,
+ showReplaceExistingFiles,
+ showImportMode,
+ filterExistingFiles,
+ replaceExistingFiles,
+ title,
+ folder,
+ isFetching,
+ isPopulated,
+ isSaving,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ importMode,
+ interactiveImportErrorMessage,
+ onSortPress,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ invalidRowsSelected,
+ selectModalOpen,
+ albumsImported,
+ isConfirmImportModalOpen,
+ showClearTracks,
+ inconsistentAlbumReleases
+ } = this.state;
+
+ const selectedIds = this.getSelectedIds();
+ const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null;
+ const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
+
+ const bulkSelectOptions = [
+ { key: SELECT, value: 'Select...', disabled: true },
+ { key: ALBUM, value: 'Select Album' },
+ { key: ALBUM_RELEASE, value: 'Select Album Release' },
+ { key: QUALITY, value: 'Select Quality' }
+ ];
+
+ if (allowArtistChange) {
+ bulkSelectOptions.splice(1, 0, {
+ key: ARTIST,
+ value: 'Select Artist'
+ });
+ }
+
+ return (
+
+
+ Manual Import - {title || folder}
+
+
+
+
+ {
+ showFilterExistingFiles &&
+
+
+
+
+
+ {
+ filterExistingFiles ? 'Unmapped Files Only' : 'All Files'
+ }
+
+
+
+
+
+ All Files
+
+
+
+ Unmapped Files Only
+
+
+
+ }
+ {
+ showReplaceExistingFiles &&
+
+
+
+
+
+ {
+ replaceExistingFiles ? 'Existing files will be deleted' : 'Combine with existing files'
+ }
+
+
+
+
+
+ Combine With Existing Files
+
+
+
+ Replace Existing Files
+
+
+
+ }
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+ {
+ isPopulated && !!items.length && !isFetching && !isFetching &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length && !isFetching &&
+ 'No audio files were found in the selected folder'
+ }
+
+
+
+
+ {
+ !downloadId && showImportMode ?
+ :
+ null
+ }
+
+
+
+ {
+ showClearTracks ? (
+
+ Clear Tracks
+
+ ) : (
+
+ Map Tracks
+
+ )
+ }
+
+
+
+
+ Cancel
+
+
+ {
+ interactiveImportErrorMessage &&
+ {interactiveImportErrorMessage}
+ }
+
+
+ Import
+
+
+
+
+
+
+
+
+ x.album).groupBy((x) => x.album.id).mapValues((x) => x.map((y) => y.id)).value()}
+ albums={_.chain(items).filter((x) => x.album).keyBy((x) => x.album.id).mapValues((x) => ({ matchedReleaseId: x.albumReleaseId, album: x.album })).values().value()}
+ onModalClose={this.onSelectModalClose}
+ />
+
+
+
+
+
+
+ );
+ }
+}
+
+InteractiveImportModalContent.propTypes = {
+ downloadId: PropTypes.string,
+ allowArtistChange: PropTypes.bool.isRequired,
+ showImportMode: PropTypes.bool.isRequired,
+ showFilterExistingFiles: PropTypes.bool.isRequired,
+ showReplaceExistingFiles: PropTypes.bool.isRequired,
+ filterExistingFiles: PropTypes.bool.isRequired,
+ replaceExistingFiles: PropTypes.bool.isRequired,
+ importMode: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ folder: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: 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,
+ onReplaceExistingFilesChange: PropTypes.func.isRequired,
+ onImportModeChange: PropTypes.func.isRequired,
+ onImportSelectedPress: PropTypes.func.isRequired,
+ saveInteractiveImportItem: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContent.defaultProps = {
+ allowArtistChange: true,
+ showFilterExistingFiles: false,
+ showReplaceExistingFiles: 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..1bf8771ba
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
@@ -0,0 +1,226 @@
+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,
+ updateInteractiveImportItem,
+ saveInteractiveImportItem
+} 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,
+ updateInteractiveImportItem,
+ saveInteractiveImportItem,
+ executeCommand
+};
+
+class InteractiveImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ interactiveImportErrorMessage: null,
+ filterExistingFiles: props.filterExistingFiles,
+ replaceExistingFiles: props.replaceExistingFiles
+ };
+ }
+
+ componentDidMount() {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ const {
+ filterExistingFiles,
+ replaceExistingFiles
+ } = this.state;
+
+ this.props.fetchInteractiveImportItems({
+ downloadId,
+ folder,
+ filterExistingFiles,
+ replaceExistingFiles
+ });
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ filterExistingFiles,
+ replaceExistingFiles
+ } = this.state;
+
+ if (prevState.filterExistingFiles !== filterExistingFiles ||
+ prevState.replaceExistingFiles !== replaceExistingFiles) {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ this.props.fetchInteractiveImportItems({
+ downloadId,
+ folder,
+ filterExistingFiles,
+ replaceExistingFiles
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearInteractiveImport();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportSort({ sortKey, sortDirection });
+ }
+
+ onFilterExistingFilesChange = (filterExistingFiles) => {
+ this.setState({ filterExistingFiles });
+ }
+
+ onReplaceExistingFilesChange = (replaceExistingFiles) => {
+ this.setState({ replaceExistingFiles });
+ }
+
+ 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 {
+ artist,
+ album,
+ albumReleaseId,
+ tracks,
+ quality,
+ disableReleaseSwitching
+ } = item;
+
+ if (!artist) {
+ this.setState({ interactiveImportErrorMessage: 'Artist must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!album) {
+ this.setState({ interactiveImportErrorMessage: 'Album must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!tracks || !tracks.length) {
+ this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!quality) {
+ this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' });
+ return false;
+ }
+
+ files.push({
+ path: item.path,
+ artistId: artist.id,
+ albumId: album.id,
+ albumReleaseId,
+ trackIds: _.map(tracks, 'id'),
+ quality,
+ downloadId: this.props.downloadId,
+ disableReleaseSwitching
+ });
+ }
+ });
+
+ if (!files.length) {
+ return;
+ }
+
+ this.props.executeCommand({
+ name: commandNames.INTERACTIVE_IMPORT,
+ files,
+ importMode,
+ replaceExistingFiles: this.state.replaceExistingFiles
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ interactiveImportErrorMessage,
+ filterExistingFiles,
+ replaceExistingFiles
+ } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+InteractiveImportModalContentConnector.propTypes = {
+ downloadId: PropTypes.string,
+ folder: PropTypes.string,
+ filterExistingFiles: PropTypes.bool.isRequired,
+ replaceExistingFiles: 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,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContentConnector.defaultProps = {
+ filterExistingFiles: true,
+ replaceExistingFiles: false
+};
+
+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..8510d0649
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
@@ -0,0 +1,29 @@
+.relativePath {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
+
+.label {
+ composes: label from '~Components/Label.css';
+
+ cursor: pointer;
+}
+
+.loading {
+ composes: loading from '~Components/Loading/LoadingIndicator.css';
+
+ margin-top: 0;
+}
+
+.additionalFile {
+ composes: row from '~Components/Table/TableRow.css';
+
+ color: $disabledColor;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
new file mode 100644
index 000000000..06c2aed2a
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
@@ -0,0 +1,372 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import { icons, kinds, tooltipPositions, sortDirections } 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 Tooltip from 'Components/Tooltip/Tooltip';
+import TrackQuality from 'Album/TrackQuality';
+import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal';
+import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal';
+import SelectTrackModal from 'InteractiveImport/Track/SelectTrackModal';
+import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
+import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import styles from './InteractiveImportRow.css';
+
+class InteractiveImportRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isSelectArtistModalOpen: false,
+ isSelectAlbumModalOpen: false,
+ isSelectTrackModalOpen: false,
+ isSelectQualityModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ const {
+ id,
+ artist,
+ album,
+ tracks,
+ quality
+ } = this.props;
+
+ if (
+ artist &&
+ album != null &&
+ tracks.length &&
+ quality
+ ) {
+ this.props.onSelectedChange({ id, value: true });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ artist,
+ album,
+ tracks,
+ quality,
+ isSelected,
+ onValidRowChange
+ } = this.props;
+
+ if (
+ prevProps.artist === artist &&
+ prevProps.album === album &&
+ !hasDifferentItems(prevProps.tracks, tracks) &&
+ prevProps.quality === quality &&
+ prevProps.isSelected === isSelected
+ ) {
+ return;
+ }
+
+ const isValid = !!(
+ artist &&
+ album &&
+ tracks.length &&
+ quality
+ );
+
+ if (isSelected && !isValid) {
+ onValidRowChange(id, false);
+ } else {
+ onValidRowChange(id, true);
+ }
+ }
+
+ //
+ // Control
+
+ selectRowAfterChange = (value) => {
+ const {
+ id,
+ isSelected
+ } = this.props;
+
+ if (!isSelected && value === true) {
+ this.props.onSelectedChange({ id, value });
+ }
+ }
+
+ //
+ // Listeners
+
+ onSelectArtistPress = () => {
+ this.setState({ isSelectArtistModalOpen: true });
+ }
+
+ onSelectAlbumPress = () => {
+ this.setState({ isSelectAlbumModalOpen: true });
+ }
+
+ onSelectTrackPress = () => {
+ this.setState({ isSelectTrackModalOpen: true });
+ }
+
+ onSelectQualityPress = () => {
+ this.setState({ isSelectQualityModalOpen: true });
+ }
+
+ onSelectArtistModalClose = (changed) => {
+ this.setState({ isSelectArtistModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectAlbumModalClose = (changed) => {
+ this.setState({ isSelectAlbumModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectTrackModalClose = (changed) => {
+ this.setState({ isSelectTrackModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectQualityModalClose = (changed) => {
+ this.setState({ isSelectQualityModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ allowArtistChange,
+ relativePath,
+ artist,
+ album,
+ albumReleaseId,
+ tracks,
+ quality,
+ size,
+ rejections,
+ audioTags,
+ additionalFile,
+ isSelected,
+ isSaving,
+ onSelectedChange
+ } = this.props;
+
+ const {
+ isSelectArtistModalOpen,
+ isSelectAlbumModalOpen,
+ isSelectTrackModalOpen,
+ isSelectQualityModalOpen
+ } = this.state;
+
+ const artistName = artist ? artist.artistName : '';
+ let albumTitle = '';
+ if (album) {
+ albumTitle = album.disambiguation ? `${album.title} (${album.disambiguation})` : album.title;
+ }
+
+ const sortedTracks = tracks.sort((a, b) => parseInt(a.absoluteTrackNumber) - parseInt(b.absoluteTrackNumber));
+
+ const trackNumbers = sortedTracks.map((track) => `${track.mediumNumber}x${track.trackNumber}`)
+ .join(', ');
+
+ const showArtistPlaceholder = isSelected && !artist;
+ const showAlbumNumberPlaceholder = isSelected && !!artist && !album;
+ const showTrackNumbersPlaceholder = !isSaving && isSelected && !!album && !tracks.length;
+ const showTrackNumbersLoading = isSaving && !tracks.length;
+ const showQualityPlaceholder = isSelected && !quality;
+
+ const pathCellContents = (
+
+ {relativePath}
+
+ );
+
+ const pathCell = additionalFile ? (
+
+ ) : pathCellContents;
+
+ return (
+
+
+
+
+ {pathCell}
+
+
+
+ {
+ showArtistPlaceholder ? : artistName
+ }
+
+
+
+ {
+ showAlbumNumberPlaceholder ? : albumTitle
+ }
+
+
+
+ {
+ showTrackNumbersLoading &&
+ }
+ {
+ showTrackNumbersPlaceholder ? : trackNumbers
+ }
+
+
+
+ {
+ showQualityPlaceholder &&
+
+ }
+
+ {
+ !showQualityPlaceholder && !!quality &&
+
+ }
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ rejections && rejections.length ?
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection.reason}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ /> :
+ null
+ }
+
+
+
+
+
+
+
+
+ 1 : false}
+ real={quality ? quality.revision.real > 0 : false}
+ onModalClose={this.onSelectQualityModalClose}
+ />
+
+ );
+ }
+
+}
+
+InteractiveImportRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ allowArtistChange: PropTypes.bool.isRequired,
+ relativePath: PropTypes.string.isRequired,
+ artist: PropTypes.object,
+ album: PropTypes.object,
+ albumReleaseId: PropTypes.number,
+ tracks: PropTypes.arrayOf(PropTypes.object).isRequired,
+ quality: PropTypes.object,
+ size: PropTypes.number.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
+ audioTags: PropTypes.object.isRequired,
+ additionalFile: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ isSaving: PropTypes.bool.isRequired,
+ onSelectedChange: PropTypes.func.isRequired,
+ onValidRowChange: PropTypes.func.isRequired
+};
+
+InteractiveImportRow.defaultProps = {
+ tracks: []
+};
+
+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..1cf55cde6
--- /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 { updateInteractiveImportItems } 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 = {
+ dispatchFetchQualityProfileSchema: fetchQualityProfileSchema,
+ dispatchUpdateInteractiveImportItems: updateInteractiveImportItems
+};
+
+class SelectQualityModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+ }
+
+ //
+ // 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.dispatchUpdateInteractiveImportItems({
+ ids: this.props.ids,
+ quality: {
+ quality,
+ revision
+ }
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectQualityModalContentConnector.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.number).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
+ dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModal.js b/frontend/src/InteractiveImport/Track/SelectTrackModal.js
new file mode 100644
index 000000000..f8c9c4160
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectTrackModalContentConnector from './SelectTrackModalContentConnector';
+
+class SelectTrackModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectTrackModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectTrackModal;
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js
new file mode 100644
index 000000000..0934cc047
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js
@@ -0,0 +1,236 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import _ from 'lodash';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import SelectTrackRow from './SelectTrackRow';
+import ExpandingFileDetails from 'TrackFile/ExpandingFileDetails';
+
+const columns = [
+ {
+ name: 'mediumNumber',
+ label: 'Medium',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackNumber',
+ label: '#',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isVisible: true
+ },
+ {
+ name: 'trackStatus',
+ label: 'Status',
+ isVisible: true
+ }
+];
+
+const selectAllBlankColumn = [
+ {
+ name: 'dummy',
+ label: ' ',
+ isVisible: true
+ }
+];
+
+class SelectTrackModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const selectedTracks = _.filter(props.selectedTracksByItem, ['id', props.id])[0].tracks;
+ const init = _.zipObject(selectedTracks, _.times(selectedTracks.length, _.constant(true)));
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: init
+ };
+
+ props.onSortPress( props.sortKey, props.sortDirection );
+ }
+
+ //
+ // 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);
+ });
+ }
+
+ onTracksSelect = () => {
+ this.props.onTracksSelect(this.getSelectedIds());
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ audioTags,
+ rejections,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ onSortPress,
+ onModalClose,
+ selectedTracksByItem,
+ filename
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const errorMessage = getErrorMessage(error, 'Unable to load tracks');
+
+ // all tracks selected for other items
+ const otherSelected = _.map(_.filter(selectedTracksByItem, (item) => {
+ return item.id !== id;
+ }), (x) => {
+ return x.tracks;
+ }).flat();
+ // tracks selected for the current file
+ const currentSelected = _.keys(_.pickBy(selectedState, _.identity)).map(Number);
+ // only enable selectAll if no other files have any tracks selected.
+ const selectAllEnabled = otherSelected.length === 0;
+
+ return (
+
+
+ Manual Import - Select Track(s):
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+
+
+ {
+ isPopulated && !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length &&
+ 'No tracks were found for the selected album'
+ }
+
+
+
+
+ Cancel
+
+
+
+ Select Tracks
+
+
+
+ );
+ }
+}
+
+SelectTrackModalContent.propTypes = {
+ id: PropTypes.number.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
+ audioTags: PropTypes.object.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ onSortPress: PropTypes.func.isRequired,
+ onTracksSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ selectedTracksByItem: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filename: PropTypes.string.isRequired
+};
+
+export default SelectTrackModalContent;
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js
new file mode 100644
index 000000000..35b17ade5
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js
@@ -0,0 +1,112 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchTracks, setTracksSort, clearTracks } from 'Store/Actions/trackActions';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import SelectTrackModalContent from './SelectTrackModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('tracks'),
+ createClientSideCollectionSelector('interactiveImport'),
+ (tracks, interactiveImport) => {
+
+ const selectedTracksByItem = _.map(interactiveImport.items, (item) => {
+ return { id: item.id, tracks: _.map(item.tracks, (track) => {
+ return track.id;
+ }) };
+ });
+
+ return {
+ ...tracks,
+ selectedTracksByItem
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchTracks,
+ setTracksSort,
+ clearTracks,
+ updateInteractiveImportItem
+};
+
+class SelectTrackModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId,
+ albumId,
+ albumReleaseId
+ } = this.props;
+
+ this.props.fetchTracks({ artistId, albumId, albumReleaseId });
+ }
+
+ componentWillUnmount() {
+ // This clears the tracks for the queue and hides the queue
+ // We'll need another place to store tracks for manual import
+ this.props.clearTracks();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setTracksSort({ sortKey, sortDirection });
+ }
+
+ onTracksSelect = (trackIds) => {
+ const tracks = _.reduce(this.props.items, (acc, item) => {
+ if (trackIds.indexOf(item.id) > -1) {
+ acc.push(item);
+ }
+
+ return acc;
+ }, []);
+
+ this.props.updateInteractiveImportItem({
+ id: this.props.id,
+ tracks: _.sortBy(tracks, 'trackNumber')
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectTrackModalContentConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number.isRequired,
+ albumReleaseId: PropTypes.number.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
+ audioTags: PropTypes.object.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchTracks: PropTypes.func.isRequired,
+ setTracksSort: PropTypes.func.isRequired,
+ clearTracks: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectTrackModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackRow.js b/frontend/src/InteractiveImport/Track/SelectTrackRow.js
new file mode 100644
index 000000000..f7dea7af3
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackRow.js
@@ -0,0 +1,121 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+
+class SelectTrackRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ id,
+ isSelected
+ } = this.props;
+
+ this.props.onSelectedChange({ id, value: !isSelected });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ mediumNumber,
+ trackNumber,
+ title,
+ hasFile,
+ importSelected,
+ isSelected,
+ isDisabled,
+ onSelectedChange
+ } = this.props;
+
+ let iconName = icons.UNKNOWN;
+ let iconKind = kinds.DEFAULT;
+ let iconTip = '';
+
+ if (hasFile && !importSelected) {
+ iconName = icons.DOWNLOADED;
+ iconKind = kinds.DEFAULT;
+ iconTip = 'Track already in library.';
+ } else if (!hasFile && !importSelected) {
+ iconName = icons.UNKNOWN;
+ iconKind = kinds.DEFAULT;
+ iconTip = 'Track missing from library and no import selected.';
+ } else if (importSelected && hasFile) {
+ iconName = icons.FILEIMPORT;
+ iconKind = kinds.WARNING;
+ iconTip = 'Warning: Existing track will be replaced by download.';
+ } else if (importSelected && !hasFile) {
+ iconName = icons.FILEIMPORT;
+ iconKind = kinds.DEFAULT;
+ iconTip = 'Track missing from library and selected for import.';
+ }
+
+ // isDisabled can only be true if importSelected is true
+ if (isDisabled) {
+ iconTip = `${iconTip}\nAnother file is selected to import for this track.`;
+ }
+
+ return (
+
+
+
+
+ {mediumNumber}
+
+
+
+ {trackNumber}
+
+
+
+ {title}
+
+
+
+
+ }
+ title={'Track status'}
+ body={iconTip}
+ position={tooltipPositions.LEFT}
+ />
+
+
+ );
+ }
+}
+
+SelectTrackRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ mediumNumber: PropTypes.number.isRequired,
+ trackNumber: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ importSelected: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ isDisabled: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default SelectTrackRow;
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..bc47e4e96
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearch.js
@@ -0,0 +1,204 @@
+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: 'preferredWordScore',
+ label: React.createElement(Icon, {
+ name: icons.SCORE,
+ title: 'Preferred word score'
+ }),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, {
+ name: icons.DANGER,
+ title: 'Rejections'
+ }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ },
+ {
+ name: 'releaseWeight',
+ label: React.createElement(Icon, { name: icons.DOWNLOAD }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ }
+];
+
+function InteractiveSearch(props) {
+ const {
+ 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 album 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..b8b764aa7
--- /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 === 'album' ?
+ releaseActions.setAlbumReleasesFilter :
+ releaseActions.setArtistReleasesFilter;
+
+ 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..5f79d0ec1
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setAlbumReleasesFilter, setArtistReleasesFilter } 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 === 'album' ?
+ setAlbumReleasesFilter:
+ setArtistReleasesFilter;
+
+ 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..3ec30e184
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
@@ -0,0 +1,51 @@
+.protocol {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 80px;
+}
+
+.title {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.indexer {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 85px;
+}
+
+.quality {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
+
+.preferredWordScore {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 55px;
+ font-weight: bold;
+ cursor: default;
+}
+
+.rejected,
+.download {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.age,
+.size {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
+
+.peers {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 75px;
+}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
new file mode 100644
index 000000000..055054f70
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
@@ -0,0 +1,260 @@
+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 TrackQuality from 'Album/TrackQuality';
+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,
+ preferredWordScore,
+ rejections,
+ downloadAllowed,
+ isGrabbing,
+ isGrabbed,
+ longDateFormat,
+ timeFormat,
+ grabError
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+ {formatAge(age, ageHours, ageMinutes)}
+
+
+
+
+ {title}
+
+
+
+
+ {indexer}
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ protocol === 'torrent' &&
+
+ }
+
+
+
+
+
+
+
+ {preferredWordScore > 0 && `+${preferredWordScore}`}
+ {preferredWordScore < 0 && preferredWordScore}
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+ {
+
+ }
+
+
+
+
+ );
+ }
+}
+
+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,
+ indexer: PropTypes.string.isRequired,
+ indexerId: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ seeders: PropTypes.number,
+ leechers: PropTypes.number,
+ quality: PropTypes.object.isRequired,
+ preferredWordScore: PropTypes.number.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
+ downloadAllowed: PropTypes.bool.isRequired,
+ isGrabbing: PropTypes.bool.isRequired,
+ isGrabbed: PropTypes.bool.isRequired,
+ grabError: PropTypes.string,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ 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..26654f63c
--- /dev/null
+++ b/frontend/src/InteractiveSearch/Peers.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function getKind(seeders) {
+ if (seeders > 50) {
+ return kinds.PRIMARY;
+ }
+
+ if (seeders > 10) {
+ return kinds.INFO;
+ }
+
+ if (seeders > 0) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+function getPeersTooltipPart(peers, peersUnit) {
+ if (peers == null) {
+ return `Unknown ${peersUnit}s`;
+ }
+
+ if (peers === 1) {
+ return `1 ${peersUnit}`;
+ }
+
+ return `${peers} ${peersUnit}s`;
+}
+
+function Peers(props) {
+ const {
+ seeders,
+ leechers
+ } = props;
+
+ const kind = getKind(seeders);
+
+ return (
+
+ {seeders == null ? '-' : seeders} / {leechers == null ? '-' : leechers}
+
+ );
+}
+
+Peers.propTypes = {
+ seeders: PropTypes.number,
+ leechers: PropTypes.number
+};
+
+export default Peers;
diff --git a/frontend/src/Organize/OrganizePreviewModal.js b/frontend/src/Organize/OrganizePreviewModal.js
new file mode 100644
index 000000000..647f4ddf8
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import OrganizePreviewModalContentConnector from './OrganizePreviewModalContentConnector';
+
+function OrganizePreviewModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+OrganizePreviewModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizePreviewModal;
diff --git a/frontend/src/Organize/OrganizePreviewModalConnector.js b/frontend/src/Organize/OrganizePreviewModalConnector.js
new file mode 100644
index 000000000..ace733c86
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearOrganizePreview } from 'Store/Actions/organizePreviewActions';
+import OrganizePreviewModal from './OrganizePreviewModal';
+
+const mapDispatchToProps = {
+ clearOrganizePreview
+};
+
+class OrganizePreviewModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearOrganizePreview();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OrganizePreviewModalConnector.propTypes = {
+ clearOrganizePreview: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(OrganizePreviewModalConnector);
diff --git a/frontend/src/Organize/OrganizePreviewModalContent.css b/frontend/src/Organize/OrganizePreviewModalContent.css
new file mode 100644
index 000000000..cf20af7a2
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.css
@@ -0,0 +1,24 @@
+.path {
+ margin-left: 5px;
+ font-weight: bold;
+}
+
+.trackFormat {
+ 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..6f20a9d3c
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.js
@@ -0,0 +1,192 @@
+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,
+ trackFormat,
+ 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 &&
+ Success! My work is done, no files to rename.
+ }
+
+ {
+ !isFetching && isPopulated && !!items.length &&
+
+
+
+ All paths are relative to:
+
+ {path}
+
+
+
+
+ Naming pattern:
+
+ {trackFormat}
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ {
+ isPopulated && !!items.length &&
+
+ }
+
+
+ Cancel
+
+
+
+ Organize
+
+
+
+ );
+ }
+}
+
+OrganizePreviewModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ path: PropTypes.string.isRequired,
+ trackFormat: 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..deec48a13
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions';
+import { fetchNamingSettings } from 'Store/Actions/settingsActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import OrganizePreviewModalContent from './OrganizePreviewModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.organizePreview,
+ (state) => state.settings.naming,
+ createArtistSelector(),
+ (organizePreview, naming, artist) => {
+ const props = { ...organizePreview };
+ props.isFetching = organizePreview.isFetching || naming.isFetching;
+ props.isPopulated = organizePreview.isPopulated && naming.isPopulated;
+ props.error = organizePreview.error || naming.error;
+ props.trackFormat = naming.item.standardTrackFormat;
+ props.path = artist.path;
+
+ return props;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchOrganizePreview,
+ fetchNamingSettings,
+ executeCommand
+};
+
+class OrganizePreviewModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId,
+ albumId
+ } = this.props;
+
+ this.props.fetchOrganizePreview({
+ artistId,
+ albumId
+ });
+
+ this.props.fetchNamingSettings();
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = (files) => {
+ this.props.executeCommand({
+ name: commandNames.RENAME_FILES,
+ artistId: this.props.artistId,
+ files
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OrganizePreviewModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ albumId: 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/Retag/RetagPreviewModal.js b/frontend/src/Retag/RetagPreviewModal.js
new file mode 100644
index 000000000..6abcfa09a
--- /dev/null
+++ b/frontend/src/Retag/RetagPreviewModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import RetagPreviewModalContentConnector from './RetagPreviewModalContentConnector';
+
+function RetagPreviewModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+RetagPreviewModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RetagPreviewModal;
diff --git a/frontend/src/Retag/RetagPreviewModalConnector.js b/frontend/src/Retag/RetagPreviewModalConnector.js
new file mode 100644
index 000000000..fa2e69d20
--- /dev/null
+++ b/frontend/src/Retag/RetagPreviewModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearRetagPreview } from 'Store/Actions/retagPreviewActions';
+import RetagPreviewModal from './RetagPreviewModal';
+
+const mapDispatchToProps = {
+ clearRetagPreview
+};
+
+class RetagPreviewModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearRetagPreview();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RetagPreviewModalConnector.propTypes = {
+ clearRetagPreview: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(RetagPreviewModalConnector);
diff --git a/frontend/src/Retag/RetagPreviewModalContent.css b/frontend/src/Retag/RetagPreviewModalContent.css
new file mode 100644
index 000000000..cf20af7a2
--- /dev/null
+++ b/frontend/src/Retag/RetagPreviewModalContent.css
@@ -0,0 +1,24 @@
+.path {
+ margin-left: 5px;
+ font-weight: bold;
+}
+
+.trackFormat {
+ 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/Retag/RetagPreviewModalContent.js b/frontend/src/Retag/RetagPreviewModalContent.js
new file mode 100644
index 000000000..5530d63fb
--- /dev/null
+++ b/frontend/src/Retag/RetagPreviewModalContent.js
@@ -0,0 +1,186 @@
+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 RetagPreviewRow from './RetagPreviewRow';
+import styles from './RetagPreviewModalContent.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+class RetagPreviewModalContent 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);
+ });
+ }
+
+ onRetagPress = () => {
+ this.props.onRetagPress(this.getSelectedIds());
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ path,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const selectAllValue = getValue(allSelected, allUnselected);
+
+ return (
+
+
+ Write Metadata Tags
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Error loading previews
+ }
+
+ {
+ !isFetching && ((isPopulated && !items.length)) &&
+ Success! My work is done, no files to retag.
+ }
+
+ {
+ !isFetching && isPopulated && !!items.length &&
+
+
+
+ All paths are relative to:
+
+ {path}
+
+
+
+ MusicBrainz identifiers will also be added to the files; these are not shown below.
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ {
+ isPopulated && !!items.length &&
+
+ }
+
+
+ Cancel
+
+
+
+ Retag
+
+
+
+ );
+ }
+}
+
+RetagPreviewModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ path: PropTypes.string.isRequired,
+ onRetagPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RetagPreviewModalContent;
diff --git a/frontend/src/Retag/RetagPreviewModalContentConnector.js b/frontend/src/Retag/RetagPreviewModalContentConnector.js
new file mode 100644
index 000000000..ce3a64776
--- /dev/null
+++ b/frontend/src/Retag/RetagPreviewModalContentConnector.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { fetchRetagPreview } from 'Store/Actions/retagPreviewActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import RetagPreviewModalContent from './RetagPreviewModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.retagPreview,
+ createArtistSelector(),
+ (retagPreview, artist) => {
+ const props = { ...retagPreview };
+ props.isFetching = retagPreview.isFetching;
+ props.isPopulated = retagPreview.isPopulated;
+ props.error = retagPreview.error;
+ props.path = artist.path;
+
+ return props;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRetagPreview,
+ executeCommand
+};
+
+class RetagPreviewModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId,
+ albumId
+ } = this.props;
+
+ this.props.fetchRetagPreview({
+ artistId,
+ albumId
+ });
+ }
+
+ //
+ // Listeners
+
+ onRetagPress = (files) => {
+ this.props.executeCommand({
+ name: commandNames.RETAG_FILES,
+ artistId: this.props.artistId,
+ files
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RetagPreviewModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number,
+ isPopulated: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ fetchRetagPreview: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RetagPreviewModalContentConnector);
diff --git a/frontend/src/Retag/RetagPreviewRow.css b/frontend/src/Retag/RetagPreviewRow.css
new file mode 100644
index 000000000..e59b03f19
--- /dev/null
+++ b/frontend/src/Retag/RetagPreviewRow.css
@@ -0,0 +1,26 @@
+.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;
+ }
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+}
+
+.selectedContainer {
+ margin-right: 30px;
+}
+
+.path {
+ margin-left: 10px;
+ font-weight: bold;
+}
diff --git a/frontend/src/Retag/RetagPreviewRow.js b/frontend/src/Retag/RetagPreviewRow.js
new file mode 100644
index 000000000..e02246253
--- /dev/null
+++ b/frontend/src/Retag/RetagPreviewRow.js
@@ -0,0 +1,105 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './RetagPreviewRow.css';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+
+function formatValue(field, value) {
+ if (value === undefined || value === 0 || value === '0' || value === '') {
+ return ( );
+ }
+ if (field === 'Image Size') {
+ return formatBytes(value);
+ }
+ return value;
+}
+
+function formatChange(field, oldValue, newValue) {
+ return (
+ {formatValue(field, oldValue)} {formatValue(field, newValue)}
+ );
+}
+
+class RetagPreviewRow 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,
+ path,
+ changes,
+ isSelected
+ } = this.props;
+
+ return (
+
+
+
+
+
+ {path}
+
+
+
+ {
+ changes.map(({ field, oldValue, newValue }) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+RetagPreviewRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ changes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default RetagPreviewRow;
diff --git a/frontend/src/RootFolder/RootFolderRow.css b/frontend/src/RootFolder/RootFolderRow.css
new file mode 100644
index 000000000..c1ec2625e
--- /dev/null
+++ b/frontend/src/RootFolder/RootFolderRow.css
@@ -0,0 +1,27 @@
+.link {
+ composes: link from '~Components/Link/Link.css';
+}
+
+.unavailablePath {
+ display: flex;
+ align-items: center;
+}
+
+.unavailableLabel {
+ composes: label from '~Components/Label.css';
+
+ margin-left: 10px;
+}
+
+.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..ffa836dc2
--- /dev/null
+++ b/frontend/src/RootFolder/RootFolderRow.js
@@ -0,0 +1,80 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+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 || '-';
+ const isUnavailable = freeSpace == null;
+
+ return (
+
+
+ {
+ isUnavailable ?
+
+ {path}
+
+
+ Unavailable
+
+
:
+
+
+ {path}
+
+ }
+
+
+
+ {freeSpace ? formatBytes(freeSpace) : '-'}
+
+
+
+ {unmappedFoldersCount}
+
+
+
+
+
+
+ );
+}
+
+RootFolderRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number,
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired
+};
+
+RootFolderRow.defaultProps = {
+ 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..5f0d3b9f2
--- /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..a3d90cc5a
--- /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..5da3e34dc
--- /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 Download Client
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new downloadClient, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+
+ Lidarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
+ For more information on the individual downloadClients, click 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..8eea80383
--- /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..81b4f1510
--- /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..8e1c16507
--- /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..6071479d8
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js
@@ -0,0 +1,176 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditDownloadClientModalContent.css';
+
+class EditDownloadClientModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteDownloadClientPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ enable,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Download Client - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new download client, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+EditDownloadClientModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isTesting: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteDownloadClientPress: PropTypes.func
+};
+
+export default EditDownloadClientModalContent;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
new file mode 100644
index 000000000..75f6f0bc3
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions';
+import EditDownloadClientModalContent from './EditDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('downloadClients'),
+ (advancedSettings, downloadClient) => {
+ return {
+ advancedSettings,
+ ...downloadClient
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDownloadClientValue,
+ setDownloadClientFieldValue,
+ saveDownloadClient,
+ testDownloadClient
+};
+
+class EditDownloadClientModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDownloadClientValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setDownloadClientFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveDownloadClient({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testDownloadClient({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDownloadClientModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDownloadClientValue: PropTypes.func.isRequired,
+ setDownloadClientFieldValue: PropTypes.func.isRequired,
+ saveDownloadClient: PropTypes.func.isRequired,
+ testDownloadClient: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
new file mode 100644
index 000000000..c345feb5b
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
@@ -0,0 +1,116 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function DownloadClientOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
Unable to load download client options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+
+
+
+
+
+
+
+
+ }
+
+ );
+}
+
+DownloadClientOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default DownloadClientOptions;
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
new file mode 100644
index 000000000..d709481b1
--- /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: 'settings.downloadClientOptions' });
+ }
+
+ //
+ // 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..97e132552
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css
@@ -0,0 +1,11 @@
+.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..afb891e0f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js
@@ -0,0 +1,150 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditRemotePathMappingModalContent.css';
+
+function EditRemotePathMappingModalContent(props) {
+ const {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ downloadClientHosts,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteRemotePathMappingPress,
+ ...otherProps
+ } = props;
+
+ const {
+ host,
+ remotePath,
+ localPath
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Remote Path Mapping' : 'Add Remote Path Mapping'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new remote path mapping, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+const remotePathMappingShape = {
+ host: PropTypes.shape(stringSettingShape).isRequired,
+ remotePath: PropTypes.shape(stringSettingShape).isRequired,
+ localPath: PropTypes.shape(stringSettingShape).isRequired
+};
+
+EditRemotePathMappingModalContent.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.shape(remotePathMappingShape).isRequired,
+ downloadClientHosts: PropTypes.arrayOf(PropTypes.object).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..df7f59f52
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js
@@ -0,0 +1,148 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setRemotePathMappingValue, saveRemotePathMapping } from 'Store/Actions/settingsActions';
+import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent';
+
+const newRemotePathMapping = {
+ host: '',
+ remotePath: '',
+ localPath: ''
+};
+
+const selectDownloadClientHosts = createSelector(
+ (state) => state.settings.downloadClients.items,
+ (downloadClients) => {
+ const hosts = downloadClients.reduce((acc, downloadClient) => {
+ const name = downloadClient.name;
+ const host = downloadClient.fields.find((field) => {
+ return field.name === 'host';
+ });
+
+ if (host) {
+ const group = acc[host.value] = acc[host.value] || [];
+ group.push(name);
+ }
+
+ return acc;
+ }, {});
+
+ return Object.keys(hosts).map((host) => {
+ return {
+ key: host,
+ value: host,
+ hint: `${hosts[host].join(', ')}`
+ };
+ });
+ }
+);
+
+function createRemotePathMappingSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.remotePathMappings,
+ selectDownloadClientHosts,
+ (id, remotePathMappings, downloadClientHosts) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = remotePathMappings;
+
+ const mapping = id ? _.find(items, { id }) : newRemotePathMapping;
+ const settings = selectSettings(mapping, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings,
+ downloadClientHosts
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createRemotePathMappingSelector(),
+ (remotePathMapping) => {
+ return {
+ ...remotePathMapping
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchSetRemotePathMappingValue: setRemotePathMappingValue,
+ dispatchSaveRemotePathMapping: saveRemotePathMapping
+};
+
+class EditRemotePathMappingModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newRemotePathMapping).forEach((name) => {
+ this.props.dispatchSetRemotePathMappingValue({
+ name,
+ value: newRemotePathMapping[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetRemotePathMappingValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.dispatchSaveRemotePathMapping({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRemotePathMappingModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ dispatchSetRemotePathMappingValue: PropTypes.func.isRequired,
+ dispatchSaveRemotePathMapping: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css
new file mode 100644
index 000000000..13f35bed4
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css
@@ -0,0 +1,27 @@
+.remotePathMapping {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid $borderColor;
+ line-height: 30px;
+}
+
+.host {
+ @add-mixin truncate;
+
+ flex: 0 1 300px;
+}
+
+.path {
+ @add-mixin truncate;
+
+ flex: 0 1 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..0633b28ff
--- /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..6d0079fd9
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css
@@ -0,0 +1,27 @@
+.remotePathMappingsHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.host {
+ @add-mixin truncate;
+
+ flex: 0 1 300px;
+}
+
+.path {
+ @add-mixin truncate;
+
+ flex: 0 1 400px;
+}
+
+.addRemotePathMapping {
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 10px;
+}
+
+.addButton {
+ text-align: center;
+}
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
new file mode 100644
index 000000000..f633a3279
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import RemotePathMapping from './RemotePathMapping';
+import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
+import styles from './RemotePathMappings.css';
+
+class RemotePathMappings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddRemotePathMappingModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddRemotePathMappingPress = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteRemotePathMapping,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
Host
+
Remote Path
+
Local Path
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RemotePathMappings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default RemotePathMappings;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js
new file mode 100644
index 000000000..7a029818a
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchRemotePathMappings, deleteRemotePathMapping } from 'Store/Actions/settingsActions';
+import RemotePathMappings from './RemotePathMappings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.remotePathMappings,
+ (remotePathMappings) => {
+ return {
+ ...remotePathMappings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchRemotePathMappings: fetchRemotePathMappings,
+ dispatchDeleteRemotePathMapping: deleteRemotePathMapping
+};
+
+class RemotePathMappingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchRemotePathMappings();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteRemotePathMapping = (id) => {
+ this.props.dispatchDeleteRemotePathMapping({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RemotePathMappingsConnector.propTypes = {
+ dispatchFetchRemotePathMappings: PropTypes.func.isRequired,
+ dispatchDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector);
diff --git a/frontend/src/Settings/General/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js
new file mode 100644
index 000000000..6854e6d62
--- /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..9ed2c5035
--- /dev/null
+++ b/frontend/src/Settings/General/BackupSettings.js
@@ -0,0 +1,84 @@
+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..485763610
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettings.js
@@ -0,0 +1,217 @@
+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';
+
+const requiresRestartKeys = [
+ 'bindAddress',
+ 'port',
+ 'urlBase',
+ 'enableSsl',
+ 'sslPort',
+ 'sslCertHash',
+ 'authenticationMethod',
+ 'username',
+ 'password',
+ 'apiKey'
+];
+
+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 pendingRestart = _.some(requiresRestartKeys, (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,
+ isWindowsService,
+ isDocker,
+ 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,
+ isWindowsService: PropTypes.bool.isRequired,
+ isDocker: 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..1c64b5724
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettingsConnector.js
@@ -0,0 +1,111 @@
+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,
+ isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service',
+ isDocker: systemStatus.isDocker,
+ 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: `settings.${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..2f3de8562
--- /dev/null
+++ b/frontend/src/Settings/General/HostSettings.js
@@ -0,0 +1,157 @@
+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
+
+
+ :
+ null
+ }
+
+ {
+ isWindows && enableSsl.value ?
+
+ SSL Cert Hash
+
+
+ :
+ null
+ }
+
+ {
+ isWindows && mode !== 'service' ?
+
+ Open browser on start
+
+
+ :
+ null
+ }
+
+
+ );
+}
+
+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..39ec2fb63
--- /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';
+
+const logLevelOptions = [
+ { key: 'info', value: 'Info' },
+ { key: 'debug', value: 'Debug' },
+ { key: 'trace', value: 'Trace' }
+];
+
+function LoggingSettings(props) {
+ const {
+ settings,
+ onInputChange
+ } = props;
+
+ const {
+ logLevel
+ } = settings;
+
+ 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..5febc6b3a
--- /dev/null
+++ b/frontend/src/Settings/General/ProxySettings.js
@@ -0,0 +1,141 @@
+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..82ed39d0c
--- /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..d41f95f35
--- /dev/null
+++ b/frontend/src/Settings/General/UpdateSettings.js
@@ -0,0 +1,133 @@
+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';
+
+const branchValues = [
+ 'develop',
+ 'nightly'
+];
+
+function UpdateSettings(props) {
+ const {
+ advancedSettings,
+ settings,
+ isMono,
+ isDocker,
+ onInputChange
+ } = props;
+
+ const {
+ branch,
+ updateAutomatically,
+ updateMechanism,
+ updateScriptPath
+ } = settings;
+
+ if (!advancedSettings) {
+ return null;
+ }
+
+ const updateOptions = [
+ { key: 'builtIn', value: 'Built-In' },
+ { key: 'script', value: 'Script' }
+ ];
+
+ if (isDocker) {
+ return (
+
+ Updating is disabled inside a docker container. Update the container image instead.
+
+ );
+ }
+
+ return (
+
+
+ Branch
+
+
+
+
+ {
+ isMono &&
+
+
+ Automatic
+
+
+
+
+
+ Mechanism
+
+
+
+
+ {
+ updateMechanism.value === 'script' &&
+
+ Script Path
+
+
+
+ }
+
+ }
+
+ );
+}
+
+UpdateSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ isDocker: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default UpdateSettings;
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js
new file mode 100644
index 000000000..72566b289
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.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 EditImportListExclusionModalContentConnector from './EditImportListExclusionModalContentConnector';
+
+function EditImportListExclusionModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditImportListExclusionModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditImportListExclusionModal;
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js
new file mode 100644
index 000000000..f9a511675
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.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 EditImportListExclusionModal from './EditImportListExclusionModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditImportListExclusionModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.importListExclusions' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditImportListExclusionModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditImportListExclusionModalConnector);
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css
new file mode 100644
index 000000000..97e132552
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css
@@ -0,0 +1,11 @@
+.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/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js
new file mode 100644
index 000000000..ccb2fa04a
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js
@@ -0,0 +1,135 @@
+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 './EditImportListExclusionModalContent.css';
+
+function EditImportListExclusionModalContent(props) {
+ const {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteImportListExclusionPress,
+ ...otherProps
+ } = props;
+
+ const {
+ artistName,
+ foreignId
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Import List Exclusion' : 'Add Import List Exclusion'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new import list exclusion, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+const ImportListExclusionShape = {
+ artistName: PropTypes.shape(stringSettingShape).isRequired,
+ foreignId: PropTypes.shape(stringSettingShape).isRequired
+};
+
+EditImportListExclusionModalContent.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.shape(ImportListExclusionShape).isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteImportListExclusionPress: PropTypes.func
+};
+
+export default EditImportListExclusionModalContent;
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js
new file mode 100644
index 000000000..2516ca25b
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js
@@ -0,0 +1,118 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setImportListExclusionValue, saveImportListExclusion } from 'Store/Actions/settingsActions';
+import EditImportListExclusionModalContent from './EditImportListExclusionModalContent';
+
+const newImportListExclusion = {
+ artistName: '',
+ foreignId: ''
+};
+
+function createImportListExclusionSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.importListExclusions,
+ (id, importListExclusions) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = importListExclusions;
+
+ const mapping = id ? _.find(items, { id }) : newImportListExclusion;
+ const settings = selectSettings(mapping, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createImportListExclusionSelector(),
+ (importListExclusion) => {
+ return {
+ ...importListExclusion
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setImportListExclusionValue,
+ saveImportListExclusion
+};
+
+class EditImportListExclusionModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newImportListExclusion).forEach((name) => {
+ this.props.setImportListExclusionValue({
+ name,
+ value: newImportListExclusion[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setImportListExclusionValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveImportListExclusion({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditImportListExclusionModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setImportListExclusionValue: PropTypes.func.isRequired,
+ saveImportListExclusion: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListExclusionModalContentConnector);
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css
new file mode 100644
index 000000000..4c274831c
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css
@@ -0,0 +1,23 @@
+.importListExclusion {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid $borderColor;
+ line-height: 30px;
+}
+
+.artistName {
+ flex: 0 0 300px;
+}
+
+.foreignId {
+ flex: 0 0 400px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 0 auto;
+ padding-right: 10px;
+}
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js
new file mode 100644
index 000000000..4a4d97c78
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js
@@ -0,0 +1,111 @@
+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 EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
+import styles from './ImportListExclusion.css';
+
+class ImportListExclusion extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditImportListExclusionModalOpen: false,
+ isDeleteImportListExclusionModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditImportListExclusionPress = () => {
+ this.setState({ isEditImportListExclusionModalOpen: true });
+ }
+
+ onEditImportListExclusionModalClose = () => {
+ this.setState({ isEditImportListExclusionModalOpen: false });
+ }
+
+ onDeleteImportListExclusionPress = () => {
+ this.setState({
+ isEditImportListExclusionModalOpen: false,
+ isDeleteImportListExclusionModalOpen: true
+ });
+ }
+
+ onDeleteImportListExclusionModalClose = () => {
+ this.setState({ isDeleteImportListExclusionModalOpen: false });
+ }
+
+ onConfirmDeleteImportListExclusion = () => {
+ this.props.onConfirmDeleteImportListExclusion(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ artistName,
+ foreignId
+ } = this.props;
+
+ return (
+
+
{artistName}
+
{foreignId}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ImportListExclusion.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ foreignId: PropTypes.string.isRequired,
+ onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
+};
+
+ImportListExclusion.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default ImportListExclusion;
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css
new file mode 100644
index 000000000..99e1c1e99
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css
@@ -0,0 +1,23 @@
+.importListExclusionsHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.host {
+ flex: 0 0 300px;
+}
+
+.path {
+ flex: 0 0 400px;
+}
+
+.addImportListExclusion {
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 10px;
+}
+
+.addButton {
+ text-align: center;
+}
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js
new file mode 100644
index 000000000..f84015e56
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.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 ImportListExclusion from './ImportListExclusion';
+import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
+import styles from './ImportListExclusions.css';
+
+class ImportListExclusions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddImportListExclusionModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddImportListExclusionPress = () => {
+ this.setState({ isAddImportListExclusionModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddImportListExclusionModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteImportListExclusion,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ImportListExclusions.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
+};
+
+export default ImportListExclusions;
diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js
new file mode 100644
index 000000000..c5f15f43d
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.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 { fetchImportListExclusions, deleteImportListExclusion } from 'Store/Actions/settingsActions';
+import ImportListExclusions from './ImportListExclusions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.importListExclusions,
+ (importListExclusions) => {
+ return {
+ ...importListExclusions
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchImportListExclusions,
+ deleteImportListExclusion
+};
+
+class ImportListExclusionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchImportListExclusions();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteImportListExclusion = (id) => {
+ this.props.deleteImportListExclusion({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportListExclusionsConnector.propTypes = {
+ fetchImportListExclusions: PropTypes.func.isRequired,
+ deleteImportListExclusion: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportListExclusionsConnector);
diff --git a/frontend/src/Settings/ImportLists/ImportListSettings.js b/frontend/src/Settings/ImportLists/ImportListSettings.js
new file mode 100644
index 000000000..63a8a6733
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListSettings.js
@@ -0,0 +1,90 @@
+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 ImportListsConnector from './ImportLists/ImportListsConnector';
+import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
+
+class ImportListSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setListOptionsRef = (ref) => {
+ this._listOptions = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._listOptions.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isTestingAll,
+ dispatchTestAllImportLists
+ } = this.props;
+
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+ );
+ }
+}
+
+ImportListSettings.propTypes = {
+ isTestingAll: PropTypes.bool.isRequired,
+ dispatchTestAllImportLists: PropTypes.func.isRequired
+};
+
+export default ImportListSettings;
diff --git a/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js b/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js
new file mode 100644
index 000000000..7607faef7
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllImportLists } from 'Store/Actions/settingsActions';
+import ImportListSettings from './ImportListSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.importLists.isTestingAll,
+ (isTestingAll) => {
+ return {
+ isTestingAll
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchTestAllImportLists: testAllImportLists
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportListSettings);
diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css
new file mode 100644
index 000000000..5e69c30a4
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css
@@ -0,0 +1,44 @@
+.list {
+ 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/ImportLists/ImportLists/AddImportListItem.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.js
new file mode 100644
index 000000000..21058636c
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.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 AddImportListPresetMenuItem from './AddImportListPresetMenuItem';
+import styles from './AddImportListItem.css';
+
+class AddImportListItem extends Component {
+
+ //
+ // Listeners
+
+ onImportListSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onImportListSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onImportListSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddImportListItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onImportListSelect: PropTypes.func.isRequired
+};
+
+export default AddImportListItem;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js
new file mode 100644
index 000000000..a188d6b4a
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddImportListModalContentConnector from './AddImportListModalContentConnector';
+
+function AddImportListModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddImportListModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddImportListModal;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css
new file mode 100644
index 000000000..8454ca79f
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css
@@ -0,0 +1,5 @@
+.lists {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js
new file mode 100644
index 000000000..04700237f
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js
@@ -0,0 +1,102 @@
+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 AddImportListItem from './AddImportListItem';
+import styles from './AddImportListModalContent.css';
+import titleCase from 'Utilities/String/titleCase';
+
+class AddImportListModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ listGroups,
+ onImportListSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add List
+
+
+
+ {
+ isSchemaFetching &&
+
+ }
+
+ {
+ !isSchemaFetching && !!schemaError &&
+ Unable to add a new list, please try again.
+ }
+
+ {
+ isSchemaPopulated && !schemaError &&
+
+
+
+ Lidarr supports multiple lists for importing Albums and Artists into the database.
+ For more information on the individual lists, click on the info buttons.
+
+ {
+ Object.keys(listGroups).map((key) => {
+ return (
+
+
+ {
+ listGroups[key].map((list) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddImportListModalContent.propTypes = {
+ isSchemaFetching: PropTypes.bool.isRequired,
+ isSchemaPopulated: PropTypes.bool.isRequired,
+ schemaError: PropTypes.object,
+ listGroups: PropTypes.object.isRequired,
+ onImportListSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddImportListModalContent;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js
new file mode 100644
index 000000000..e464ccb93
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.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 { fetchImportListSchema, selectImportListSchema } from 'Store/Actions/settingsActions';
+import AddImportListModalContent from './AddImportListModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.importLists,
+ (importLists) => {
+ const {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ schema
+ } = importLists;
+
+ const listGroups = _.chain(schema)
+ .sortBy((o) => o.listOrder)
+ .groupBy('listType')
+ .value();
+
+ return {
+ isSchemaFetching,
+ isSchemaPopulated,
+ schemaError,
+ listGroups
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchImportListSchema,
+ selectImportListSchema
+};
+
+class AddImportListModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchImportListSchema();
+ }
+
+ //
+ // Listeners
+
+ onImportListSelect = ({ implementation, name }) => {
+ this.props.selectImportListSchema({ implementation, presetName: name });
+ this.props.onModalClose({ listSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddImportListModalContentConnector.propTypes = {
+ fetchImportListSchema: PropTypes.func.isRequired,
+ selectImportListSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddImportListModalContentConnector);
diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js
new file mode 100644
index 000000000..477044ae0
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddImportListPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddImportListPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddImportListPresetMenuItem;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js
new file mode 100644
index 000000000..b673ae9a4
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditImportListModalContentConnector from './EditImportListModalContentConnector';
+
+function EditImportListModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditImportListModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditImportListModal;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js
new file mode 100644
index 000000000..72d39817b
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.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 { cancelTestImportList, cancelSaveImportList } from 'Store/Actions/settingsActions';
+import EditImportListModal from './EditImportListModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'settings.importLists';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ },
+
+ dispatchCancelTestImportList() {
+ dispatch(cancelTestImportList({ section }));
+ },
+
+ dispatchCancelSaveImportList() {
+ dispatch(cancelSaveImportList({ section }));
+ }
+ };
+}
+
+class EditImportListModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.dispatchCancelTestImportList();
+ this.props.dispatchCancelSaveImportList();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchClearPendingChanges,
+ dispatchCancelTestImportList,
+ dispatchCancelSaveImportList,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+EditImportListModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ dispatchCancelTestImportList: PropTypes.func.isRequired,
+ dispatchCancelSaveImportList: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditImportListModalConnector);
diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css
new file mode 100644
index 000000000..23e22b6dc
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css
@@ -0,0 +1,15 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
+.hideMetadataProfile {
+ composes: group from '~Components/Form/FormGroup.css';
+
+ display: none;
+}
+
+.labelIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js
new file mode 100644
index 000000000..6a2826802
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js
@@ -0,0 +1,278 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+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 Popover from 'Components/Tooltip/Popover';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './EditImportListModalContent.css';
+
+function ImportListMonitoringOptionsPopoverContent() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function EditImportListModalContent(props) {
+
+ const monitorOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'specificAlbum', value: 'Specific Album' },
+ { key: 'entireArtist', value: 'All Artist Albums' }
+ ];
+
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteImportListPress,
+ showMetadataProfile,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ enableAutomaticAdd,
+ shouldMonitor,
+ rootFolderPath,
+ qualityProfileId,
+ metadataProfileId,
+ tags,
+ fields
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit List' : 'Add List'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new list, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditImportListModalContent.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,
+ showMetadataProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteImportListPress: PropTypes.func
+};
+
+export default EditImportListModalContent;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js
new file mode 100644
index 000000000..527b021f2
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.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 createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setImportListValue, setImportListFieldValue, saveImportList, testImportList } from 'Store/Actions/settingsActions';
+import EditImportListModalContent from './EditImportListModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.metadataProfiles,
+ createProviderSettingsSelector('importLists'),
+ (advancedSettings, metadataProfiles, importList) => {
+ return {
+ advancedSettings,
+ showMetadataProfile: metadataProfiles.items.length > 1,
+ ...importList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setImportListValue,
+ setImportListFieldValue,
+ saveImportList,
+ testImportList
+};
+
+class EditImportListModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setImportListValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setImportListFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveImportList({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testImportList({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditImportListModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setImportListValue: PropTypes.func.isRequired,
+ setImportListFieldValue: PropTypes.func.isRequired,
+ saveImportList: PropTypes.func.isRequired,
+ testImportList: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListModalContentConnector);
diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.css b/frontend/src/Settings/ImportLists/ImportLists/ImportList.css
new file mode 100644
index 000000000..4796748f7
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.css
@@ -0,0 +1,19 @@
+.list {
+ 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/ImportLists/ImportLists/ImportList.js b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js
new file mode 100644
index 000000000..f0b1044e7
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js
@@ -0,0 +1,108 @@
+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 EditImportListModalConnector from './EditImportListModalConnector';
+import styles from './ImportList.css';
+
+class ImportList extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditImportListModalOpen: false,
+ isDeleteImportListModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditImportListPress = () => {
+ this.setState({ isEditImportListModalOpen: true });
+ }
+
+ onEditImportListModalClose = () => {
+ this.setState({ isEditImportListModalOpen: false });
+ }
+
+ onDeleteImportListPress = () => {
+ this.setState({
+ isEditImportListModalOpen: false,
+ isDeleteImportListModalOpen: true
+ });
+ }
+
+ onDeleteImportListModalClose= () => {
+ this.setState({ isDeleteImportListModalOpen: false });
+ }
+
+ onConfirmDeleteImportList = () => {
+ this.props.onConfirmDeleteImportList(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enableAutomaticAdd
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ enableAutomaticAdd &&
+
+ Automatic Add
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ImportList.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enableAutomaticAdd: PropTypes.bool.isRequired,
+ onConfirmDeleteImportList: PropTypes.func.isRequired
+};
+
+export default ImportList;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css
new file mode 100644
index 000000000..3db4e69d6
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css
@@ -0,0 +1,20 @@
+.lists {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addList {
+ composes: list from '~./ImportList.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/ImportLists/ImportLists/ImportLists.js b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js
new file mode 100644
index 000000000..7f654050e
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import ImportList from './ImportList';
+import AddImportListModal from './AddImportListModal';
+import EditImportListModalConnector from './EditImportListModalConnector';
+import styles from './ImportLists.css';
+
+class ImportLists extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddImportListModalOpen: false,
+ isEditImportListModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddImportListPress = () => {
+ this.setState({ isAddImportListModalOpen: true });
+ }
+
+ onAddImportListModalClose = ({ listSelected = false } = {}) => {
+ this.setState({
+ isAddImportListModalOpen: false,
+ isEditImportListModalOpen: listSelected
+ });
+ }
+
+ onEditImportListModalClose = () => {
+ this.setState({ isEditImportListModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteImportList,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddImportListModalOpen,
+ isEditImportListModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ImportLists.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteImportList: PropTypes.func.isRequired
+};
+
+export default ImportLists;
diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js
new file mode 100644
index 000000000..3938da4ae
--- /dev/null
+++ b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.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 { fetchImportLists, deleteImportList } from 'Store/Actions/settingsActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import ImportLists from './ImportLists';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.importLists,
+ (importLists) => {
+ return {
+ ...importLists
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchImportLists,
+ deleteImportList,
+ fetchRootFolders
+};
+
+class ListsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchImportLists();
+ this.props.fetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteImportList = (id) => {
+ this.props.deleteImportList({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ListsConnector.propTypes = {
+ fetchImportLists: PropTypes.func.isRequired,
+ deleteImportList: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ListsConnector);
diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js
new file mode 100644
index 000000000..35f816d9c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettings.js
@@ -0,0 +1,97 @@
+import PropTypes from 'prop-types';
+import React, { Component, Fragment } from 'react';
+import { icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import IndexersConnector from './Indexers/IndexersConnector';
+import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
+
+class IndexerSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._saveCallback = null;
+
+ this.state = {
+ isSaving: false,
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onChildMounted = (saveCallback) => {
+ this._saveCallback = saveCallback;
+ }
+
+ onChildStateChange = (payload) => {
+ this.setState(payload);
+ }
+
+ onSavePress = () => {
+ if (this._saveCallback) {
+ this._saveCallback();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isTestingAll,
+ dispatchTestAllIndexers
+ } = this.props;
+
+ const {
+ isSaving,
+ hasPendingChanges
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+ }
+ onSavePress={this.onSavePress}
+ />
+
+
+
+
+
+
+
+ );
+ }
+}
+
+IndexerSettings.propTypes = {
+ isTestingAll: PropTypes.bool.isRequired,
+ dispatchTestAllIndexers: PropTypes.func.isRequired
+};
+
+export default IndexerSettings;
diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js
new file mode 100644
index 000000000..1eaf098d7
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { testAllIndexers } from 'Store/Actions/settingsActions';
+import IndexerSettings from './IndexerSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers.isTestingAll,
+ (isTestingAll) => {
+ return {
+ isTestingAll
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchTestAllIndexers: testAllIndexers
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSettings);
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css
new file mode 100644
index 000000000..1010221e1
--- /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..9bfd9d1fd
--- /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 &&
+
+
+
+ Lidarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
+ For more information on the individual indexers, click 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..a2b6014df
--- /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..e2621e57f
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js
@@ -0,0 +1,192 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditIndexerModalContent.css';
+
+function EditIndexerModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteIndexerPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ implementationName,
+ name,
+ enableRss,
+ enableAutomaticSearch,
+ enableInteractiveSearch,
+ supportsRss,
+ supportsSearch,
+ fields
+ } = item;
+
+ return (
+
+
+ {`${id ? 'Edit' : 'Add'} Indexer - ${implementationName}`}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditIndexerModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteIndexerPress: PropTypes.func
+};
+
+export default EditIndexerModalContent;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
new file mode 100644
index 000000000..f993d2796
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions';
+import EditIndexerModalContent from './EditIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector('indexers'),
+ (advancedSettings, indexer) => {
+ return {
+ advancedSettings,
+ ...indexer
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setIndexerValue,
+ setIndexerFieldValue,
+ saveIndexer,
+ testIndexer
+};
+
+class EditIndexerModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setIndexerValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setIndexerFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveIndexer({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testIndexer({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditIndexerModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setIndexerValue: PropTypes.func.isRequired,
+ setIndexerFieldValue: PropTypes.func.isRequired,
+ saveIndexer: PropTypes.func.isRequired,
+ testIndexer: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.css b/frontend/src/Settings/Indexers/Indexers/Indexer.css
new file mode 100644
index 000000000..6715d2a0a
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.css
@@ -0,0 +1,19 @@
+.indexer {
+ composes: card from '~Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js
new file mode 100644
index 000000000..9269f8532
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js
@@ -0,0 +1,140 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditIndexerModalConnector from './EditIndexerModalConnector';
+import styles from './Indexer.css';
+
+class Indexer extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditIndexerModalOpen: false,
+ isDeleteIndexerModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditIndexerPress = () => {
+ this.setState({ isEditIndexerModalOpen: true });
+ }
+
+ onEditIndexerModalClose = () => {
+ this.setState({ isEditIndexerModalOpen: false });
+ }
+
+ onDeleteIndexerPress = () => {
+ this.setState({
+ isEditIndexerModalOpen: false,
+ isDeleteIndexerModalOpen: true
+ });
+ }
+
+ onDeleteIndexerModalClose= () => {
+ this.setState({ isDeleteIndexerModalOpen: false });
+ }
+
+ onConfirmDeleteIndexer = () => {
+ this.props.onConfirmDeleteIndexer(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enableRss,
+ enableAutomaticSearch,
+ enableInteractiveSearch,
+ supportsRss,
+ supportsSearch
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ {
+ supportsRss && enableRss &&
+
+ RSS
+
+ }
+
+ {
+ supportsSearch && enableAutomaticSearch &&
+
+ Automatic Search
+
+ }
+
+ {
+ supportsSearch && enableInteractiveSearch &&
+
+ Interactive Search
+
+ }
+
+ {
+ !enableRss && !enableAutomaticSearch && !enableInteractiveSearch &&
+
+ Disabled
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+Indexer.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enableRss: PropTypes.bool.isRequired,
+ enableAutomaticSearch: PropTypes.bool.isRequired,
+ enableInteractiveSearch: PropTypes.bool.isRequired,
+ supportsRss: PropTypes.bool.isRequired,
+ supportsSearch: PropTypes.bool.isRequired,
+ onConfirmDeleteIndexer: PropTypes.func.isRequired
+};
+
+export default Indexer;
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.css b/frontend/src/Settings/Indexers/Indexers/Indexers.css
new file mode 100644
index 000000000..bf2e72ba4
--- /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..9639c7737
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
@@ -0,0 +1,111 @@
+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..54a76a1d9
--- /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: 'settings.indexerOptions' });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetIndexerOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexerOptionsConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchIndexerOptions: PropTypes.func.isRequired,
+ dispatchSetIndexerOptionsValue: PropTypes.func.isRequired,
+ dispatchSaveIndexerOptions: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexerOptionsConnector);
diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js
new file mode 100644
index 000000000..c5e7e56be
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -0,0 +1,436 @@
+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';
+import AddRootFolderConnector from './RootFolder/AddRootFolderConnector';
+
+const rescanAfterRefreshOptions = [
+ { key: 'always', value: 'Always' },
+ { key: 'afterManual', value: 'After Manual Refresh' },
+ { key: 'never', value: 'Never' }
+];
+
+const allowFingerprintingOptions = [
+ { key: 'allFiles', value: 'Always' },
+ { key: 'newFiles', value: 'For new imports only' },
+ { key: 'never', value: 'Never' }
+];
+
+const downloadPropersAndRepacksOptions = [
+ { key: 'preferAndUpgrade', value: 'Prefer and Upgrade' },
+ { key: 'doNotUpgrade', value: 'Do not Upgrade Automatically' },
+ { key: 'doNotPrefer', value: 'Do not Prefer' }
+];
+
+const fileDateOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'albumReleaseDate', value: 'Album 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..5a6250392
--- /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.mediaManagement' });
+ }
+
+ //
+ // 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..59d223e92
--- /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..81367c831
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js
@@ -0,0 +1,273 @@
+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: 'standardTrackFormat',
+ album: true,
+ track: true,
+ additional: true
+ }
+ });
+ }
+
+ onMultiDiscNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'multiDiscTrackFormat',
+ album: true,
+ track: true,
+ additional: true
+ }
+ });
+ }
+
+ onArtistFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'artistFolderFormat'
+ }
+ });
+ }
+
+ onAlbumFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'albumFolderFormat',
+ album: true
+ }
+ });
+ }
+
+ onNamingModalClose = () => {
+ this.setState({ isNamingModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ examples,
+ examplesPopulated,
+ onInputChange
+ } = this.props;
+
+ const {
+ isNamingModalOpen,
+ namingModalOptions
+ } = this.state;
+
+ const renameTracks = hasSettings && settings.renameTracks.value;
+
+ const standardTrackFormatHelpTexts = [];
+ const standardTrackFormatErrors = [];
+ const multiDiscTrackFormatHelpTexts = [];
+ const multiDiscTrackFormatErrors = [];
+ const artistFolderFormatHelpTexts = [];
+ const artistFolderFormatErrors = [];
+ const albumFolderFormatHelpTexts = [];
+ const albumFolderFormatErrors = [];
+
+ if (examplesPopulated) {
+ if (examples.singleTrackExample) {
+ standardTrackFormatHelpTexts.push(`Single Track: ${examples.singleTrackExample}`);
+ } else {
+ standardTrackFormatErrors.push({ message: 'Single Track: Invalid Format' });
+ }
+
+ if (examples.multiDiscTrackExample) {
+ multiDiscTrackFormatHelpTexts.push(`Multi Disc Track: ${examples.multiDiscTrackExample}`);
+ } else {
+ multiDiscTrackFormatErrors.push({ message: 'Single Track: Invalid Format' });
+ }
+
+ if (examples.artistFolderExample) {
+ artistFolderFormatHelpTexts.push(`Example: ${examples.artistFolderExample}`);
+ } else {
+ artistFolderFormatErrors.push({ message: 'Invalid Format' });
+ }
+
+ if (examples.albumFolderExample) {
+ albumFolderFormatHelpTexts.push(`Example: ${examples.albumFolderExample}`);
+ } else {
+ albumFolderFormatErrors.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..bd11e4299
--- /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: 'settings.naming' });
+ }
+
+ //
+ // 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..c178d82cb
--- /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..f96d364e6
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
@@ -0,0 +1,541 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Button from 'Components/Link/Button';
+import SelectInput from 'Components/Form/SelectInput';
+import TextInput from 'Components/Form/TextInput';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import NamingOption from './NamingOption';
+import styles from './NamingModal.css';
+
+const separatorOptions = [
+ { key: ' ', value: 'Space ( )' },
+ { key: '.', value: 'Period (.)' },
+ { key: '_', value: 'Underscore (_)' },
+ { key: '-', value: 'Dash (-)' }
+];
+
+const caseOptions = [
+ { key: 'title', value: 'Default Case' },
+ { key: 'lower', value: 'Lower Case' },
+ { key: 'upper', value: 'Upper Case' }
+];
+
+const fileNameTokens = [
+ {
+ token: '{Artist Name} - {Album Title} - {track:00} - {Track Title} {Quality Full}',
+ example: 'Artist Name - Album Title - 01 - Track Title MP3-320 Proper'
+ },
+ {
+ token: '{Artist.Name}.{Album.Title}.{track:00}.{TrackClean.Title}.{Quality.Full}',
+ example: 'Artist.Name.Album.Title.01.Track.Title.MP3-320'
+ }
+];
+
+const artistTokens = [
+ { token: '{Artist Name}', example: 'Artist Name' },
+
+ { token: '{Artist NameThe}', example: 'Artist Name, The' },
+
+ { token: '{Artist CleanName}', example: 'Artist Name' },
+
+ { token: '{Artist Disambiguation}', example: 'Disambiguation' }
+];
+
+const albumTokens = [
+ { token: '{Album Title}', example: 'Album Title' },
+
+ { token: '{Album TitleThe}', example: 'Album Title, The' },
+
+ { token: '{Album CleanTitle}', example: 'Album Title' },
+
+ { token: '{Album Type}', example: 'Album Type' },
+
+ { token: '{Album Disambiguation}', example: 'Disambiguation' }
+];
+
+const mediumTokens = [
+ { token: '{medium:0}', example: '1' },
+ { token: '{medium:00}', example: '01' }
+];
+
+const mediumFormatTokens = [
+ { token: '{Medium Format}', example: 'CD' }
+];
+
+const trackTokens = [
+ { token: '{track:0}', example: '1' },
+ { token: '{track:00}', example: '01' }
+];
+
+const releaseDateTokens = [
+ { token: '{Release Year}', example: '2016' }
+];
+
+const trackTitleTokens = [
+ { token: '{Track Title}', example: 'Track Title' },
+ { token: '{Track CleanTitle}', example: 'Track Title' }
+];
+
+const qualityTokens = [
+ { token: '{Quality Full}', example: 'FLAC Proper' },
+ { token: '{Quality Title}', example: 'FLAC' }
+];
+
+const mediaInfoTokens = [
+ { token: '{MediaInfo AudioCodec}', example: 'FLAC' },
+ { token: '{MediaInfo AudioChannels}', example: '2.0' },
+ { token: '{MediaInfo AudioBitRate}', example: '320kbps' },
+ { token: '{MediaInfo AudioBitsPerSample}', example: '24bit' },
+ { token: '{MediaInfo AudioSampleRate}', example: '44.1kHz' }
+];
+
+const otherTokens = [
+ { token: '{Release Group}', example: 'Rls Grp' },
+ { token: '{Preferred Words}', example: 'iNTERNAL' }
+];
+
+const originalTokens = [
+ { token: '{Original Title}', example: 'Artist.Name.Album.Name.2018.FLAC-EVOLVE' },
+ { token: '{Original Filename}', example: '01 - track name' }
+];
+
+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,
+ album,
+ track,
+ additional,
+ onInputChange,
+ onModalClose
+ } = this.props;
+
+ const {
+ separator: tokenSeparator,
+ case: tokenCase
+ } = this.state;
+
+ return (
+
+
+
+ File Name Tokens
+
+
+
+
+
+
+
+
+
+ {
+ !advancedSettings &&
+
+
+ {
+ fileNameTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+
+
+ {
+ artistTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ {
+ album &&
+
+
+
+ {
+ albumTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ releaseDateTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ }
+
+ {
+ track &&
+
+
+
+ {
+ mediumTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ mediumFormatTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ trackTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+ }
+
+ {
+ additional &&
+
+
+
+ {
+ trackTitleTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ qualityTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ mediaInfoTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ otherTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ originalTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ }
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+NamingModal.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ album: PropTypes.bool.isRequired,
+ track: PropTypes.bool.isRequired,
+ additional: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+NamingModal.defaultProps = {
+ album: false,
+ track: false,
+ additional: false
+};
+
+export default NamingModal;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
new file mode 100644
index 000000000..d9f865936
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
@@ -0,0 +1,69 @@
+.option {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ margin: 3px;
+ border: 1px solid $borderColor;
+
+ &:hover {
+ .token {
+ background-color: #ddd;
+ }
+
+ .example {
+ background-color: #ccc;
+ }
+ }
+}
+
+.small {
+ width: 460px;
+}
+
+.large {
+ width: 100%;
+}
+
+.token {
+ flex: 0 0 50%;
+ padding: 6px 16px;
+ background-color: #eee;
+ font-family: $monoSpaceFontFamily;
+}
+
+.example {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ flex: 0 0 50%;
+ padding: 6px 16px;
+ background-color: #ddd;
+}
+
+.lower {
+ text-transform: lowercase;
+}
+
+.upper {
+ text-transform: uppercase;
+}
+
+.isFullFilename {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .option.small {
+ width: 100%;
+ }
+}
+
+@media only screen and (max-width: $breakpointExtraSmall) {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
new file mode 100644
index 000000000..269266a5f
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { sizes } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import styles from './NamingOption.css';
+
+class NamingOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ token,
+ tokenSeparator,
+ tokenCase,
+ isFullFilename,
+ onPress
+ } = this.props;
+
+ let tokenValue = token;
+
+ tokenValue = tokenValue.replace(/ /g, tokenSeparator);
+
+ if (tokenCase === 'lower') {
+ tokenValue = token.toLowerCase();
+ } else if (tokenCase === 'upper') {
+ tokenValue = token.toUpperCase();
+ }
+
+ onPress({ isFullFilename, tokenValue });
+ }
+
+ //
+ // Render
+ render() {
+ const {
+ token,
+ tokenSeparator,
+ example,
+ tokenCase,
+ isFullFilename,
+ size
+ } = this.props;
+
+ return (
+
+
+ {token.replace(/ /g, tokenSeparator)}
+
+
+
+ {example.replace(/ /g, tokenSeparator)}
+
+
+ );
+ }
+}
+
+NamingOption.propTypes = {
+ token: PropTypes.string.isRequired,
+ example: PropTypes.string.isRequired,
+ tokenSeparator: PropTypes.string.isRequired,
+ tokenCase: PropTypes.string.isRequired,
+ isFullFilename: PropTypes.bool.isRequired,
+ size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
+ onPress: PropTypes.func.isRequired
+};
+
+NamingOption.defaultProps = {
+ size: sizes.SMALL,
+ isFullFilename: false
+};
+
+export default NamingOption;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css
new file mode 100644
index 000000000..19b1880be
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css
@@ -0,0 +1,7 @@
+.addRootFolderButtonContainer {
+ margin-top: 20px;
+}
+
+.importButtonIcon {
+ margin-right: 8px;
+}
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js
new file mode 100644
index 000000000..3da2a55b9
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js
@@ -0,0 +1,71 @@
+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 FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import styles from './AddRootFolder.css';
+
+class AddRootFolder 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() {
+ return (
+
+
+
+ Add Root Folder
+
+
+
+
+ );
+ }
+}
+
+AddRootFolder.propTypes = {
+ onNewRootFolderSelect: PropTypes.func.isRequired
+};
+
+export default AddRootFolder;
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js
new file mode 100644
index 000000000..cd5f4c50d
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import AddRootFolder from './AddRootFolder';
+import { addRootFolder } from 'Store/Actions/rootFolderActions';
+
+function createMapDispatchToProps(dispatch) {
+ return {
+ onNewRootFolderSelect(path) {
+ dispatch(addRootFolder({ path }));
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(AddRootFolder);
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..cb461520f
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
@@ -0,0 +1,45 @@
+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();
+ 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..f87b92a81
--- /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..faf7e9613
--- /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/MetadataProvider/MetadataProvider.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js
new file mode 100644
index 000000000..f58a8ec49
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js
@@ -0,0 +1,109 @@
+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';
+
+const writeAudioTagOptions = [
+ { key: 'sync', value: 'All files; keep in sync with MusicBrainz' },
+ { key: 'allFiles', value: 'All files; initial import only' },
+ { key: 'newFiles', value: 'For new downloads only' },
+ { key: 'no', value: 'Never' }
+];
+
+function MetadataProvider(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
Unable to load Metadata Provider settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+}
+
+MetadataProvider.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 MetadataProvider;
diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js
new file mode 100644
index 000000000..7b459acf3
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.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 { setMetadataProviderValue, saveMetadataProvider, fetchMetadataProvider } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import MetadataProvider from './MetadataProvider';
+
+const SECTION = 'metadataProvider';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(SECTION),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchMetadataProvider: fetchMetadataProvider,
+ dispatchSetMetadataProviderValue: setMetadataProviderValue,
+ dispatchSaveMetadataProvider: saveMetadataProvider,
+ dispatchClearPendingChanges: clearPendingChanges
+};
+
+class MetadataProviderConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchMetadataProvider,
+ dispatchSaveMetadataProvider,
+ onChildMounted
+ } = this.props;
+
+ dispatchFetchMetadataProvider();
+ onChildMounted(dispatchSaveMetadataProvider);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges,
+ isSaving,
+ onChildStateChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving !== isSaving ||
+ prevProps.hasPendingChanges !== hasPendingChanges
+ ) {
+ onChildStateChange({
+ isSaving,
+ hasPendingChanges
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearPendingChanges({ section: 'settings.metadataProvider' });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.dispatchSetMetadataProviderValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadataProviderConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ dispatchFetchMetadataProvider: PropTypes.func.isRequired,
+ dispatchSetMetadataProviderValue: PropTypes.func.isRequired,
+ dispatchSaveMetadataProvider: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired,
+ onChildMounted: PropTypes.func.isRequired,
+ onChildStateChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProviderConnector);
diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js
new file mode 100644
index 000000000..fc7fd0bb4
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataSettings.js
@@ -0,0 +1,69 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import MetadatasConnector from './Metadata/MetadatasConnector';
+import MetadataProviderConnector from './MetadataProvider/MetadataProviderConnector';
+
+class MetadataSettings 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 MetadataSettings;
diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js
new file mode 100644
index 000000000..c9bed6501
--- /dev/null
+++ b/frontend/src/Settings/Notifications/NotificationSettings.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import NotificationsConnector from './Notifications/NotificationsConnector';
+
+function NotificationSettings() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default NotificationSettings;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css
new file mode 100644
index 000000000..a9e416098
--- /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..8e1c16507
--- /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..c328b77d8
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
@@ -0,0 +1,178 @@
+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 NotificationEventItems from './NotificationEventItems';
+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,
+ 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..d7717d8c9
--- /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..5385ecc45
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.js
@@ -0,0 +1,195 @@
+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,
+ onReleaseImport,
+ onUpgrade,
+ onRename,
+ onHealthIssue,
+ onDownloadFailure,
+ onImportFailure,
+ onTrackRetag,
+ supportsOnGrab,
+ supportsOnReleaseImport,
+ supportsOnUpgrade,
+ supportsOnRename,
+ supportsOnHealthIssue,
+ supportsOnDownloadFailure,
+ supportsOnImportFailure,
+ supportsOnTrackRetag
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+ {
+ supportsOnGrab && onGrab &&
+
+ On Grab
+
+ }
+
+ {
+ supportsOnReleaseImport && onReleaseImport &&
+
+ On Release Import
+
+ }
+
+ {
+ supportsOnUpgrade && onReleaseImport && onUpgrade &&
+
+ On Upgrade
+
+ }
+
+ {
+ supportsOnRename && onRename &&
+
+ On Rename
+
+ }
+
+ {
+ supportsOnTrackRetag && onTrackRetag &&
+
+ On Track Tag Update
+
+ }
+
+ {
+ supportsOnHealthIssue && onHealthIssue &&
+
+ On Health Issue
+
+ }
+
+ {
+ supportsOnDownloadFailure && onDownloadFailure &&
+
+ On Download Failure
+
+ }
+
+ {
+ supportsOnImportFailure && onImportFailure &&
+
+ On Import Failure
+
+ }
+
+ {
+ !onGrab && !onReleaseImport && !onRename && !onTrackRetag &&
+ !onHealthIssue && !onDownloadFailure && !onImportFailure &&
+
+ Disabled
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Notification.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ onGrab: PropTypes.bool.isRequired,
+ onReleaseImport: PropTypes.bool.isRequired,
+ onUpgrade: PropTypes.bool.isRequired,
+ onRename: PropTypes.bool.isRequired,
+ onHealthIssue: PropTypes.bool.isRequired,
+ onDownloadFailure: PropTypes.bool.isRequired,
+ onImportFailure: PropTypes.bool.isRequired,
+ onTrackRetag: PropTypes.bool.isRequired,
+ supportsOnGrab: PropTypes.bool.isRequired,
+ supportsOnReleaseImport: PropTypes.bool.isRequired,
+ supportsOnUpgrade: PropTypes.bool.isRequired,
+ supportsOnRename: PropTypes.bool.isRequired,
+ supportsOnHealthIssue: PropTypes.bool.isRequired,
+ supportsOnDownloadFailure: PropTypes.bool.isRequired,
+ supportsOnImportFailure: PropTypes.bool.isRequired,
+ supportsOnTrackRetag: PropTypes.bool.isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notification;
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css
new file mode 100644
index 000000000..b3f6aa717
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css
@@ -0,0 +1,4 @@
+.events {
+ margin-top: 10px;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js
new file mode 100644
index 000000000..e79369b92
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js
@@ -0,0 +1,160 @@
+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 FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './NotificationEventItems.css';
+
+function NotificationEventItems(props) {
+ const {
+ item,
+ onInputChange
+ } = props;
+
+ const {
+ onGrab,
+ onReleaseImport,
+ onUpgrade,
+ onRename,
+ onHealthIssue,
+ onDownloadFailure,
+ onImportFailure,
+ onTrackRetag,
+ supportsOnGrab,
+ supportsOnReleaseImport,
+ supportsOnUpgrade,
+ supportsOnRename,
+ supportsOnHealthIssue,
+ includeHealthWarnings,
+ supportsOnDownloadFailure,
+ supportsOnImportFailure,
+ supportsOnTrackRetag
+ } = item;
+
+ return (
+
+ Notification Triggers
+
+
+
+
+
+
+
+
+
+
+
+ {
+ onReleaseImport.value &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ onHealthIssue.value &&
+
+
+
+ }
+
+
+
+ );
+}
+
+NotificationEventItems.propTypes = {
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default NotificationEventItems;
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css
new file mode 100644
index 000000000..11ea6e11f
--- /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..c9b5b8358
--- /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..0cc47c7e4
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
@@ -0,0 +1,81 @@
+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
+ })
+};
+
+/* eslint-disable new-cap */
+export default DragLayer(collectDragLayer)(DelayProfileDragPreview);
+/* eslint-enable new-cap */
+
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..4661cb5a1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
@@ -0,0 +1,150 @@
+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
+};
+
+/* eslint-disable new-cap */
+export default DropTarget(
+ DELAY_PROFILE,
+ delayProfileDropTarget,
+ collectDropTarget
+)(DragSource(
+ DELAY_PROFILE,
+ delayProfileDragSource,
+ collectDragSource
+)(DelayProfileDragSource));
+/* eslint-enable new-cap */
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..a2b6014df
--- /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..432d5fdb7
--- /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/Metadata/EditMetadataProfileModal.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js
new file mode 100644
index 000000000..a437af38b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.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 EditMetadataProfileModalContentConnector from './EditMetadataProfileModalContentConnector';
+
+function EditMetadataProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditMetadataProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditMetadataProfileModal;
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js
new file mode 100644
index 000000000..edc1f1a73
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.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 EditMetadataProfileModal from './EditMetadataProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditMetadataProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.metadataProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditMetadataProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css
new file mode 100644
index 000000000..74dd1c8b7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css
@@ -0,0 +1,3 @@
+.deleteButtonContainer {
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js
new file mode 100644
index 000000000..672e2f28c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js
@@ -0,0 +1,154 @@
+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 PrimaryTypeItems from './PrimaryTypeItems';
+import SecondaryTypeItems from './SecondaryTypeItems';
+import ReleaseStatusItems from './ReleaseStatusItems';
+import styles from './EditMetadataProfileModalContent.css';
+
+function EditMetadataProfileModalContent(props) {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ item,
+ isInUse,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteMetadataProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ primaryAlbumTypes: itemPrimaryAlbumTypes,
+ secondaryAlbumTypes: itemSecondaryAlbumTypes,
+ releaseStatuses: itemReleaseStatuses
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Metadata Profile' : 'Add Metadata Profile'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new metadata profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+
+ Delete
+
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditMetadataProfileModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ releaseStatuses: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteMetadataProfilePress: PropTypes.func
+};
+
+export default EditMetadataProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js
new file mode 100644
index 000000000..6fd45d3c9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js
@@ -0,0 +1,213 @@
+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 { fetchMetadataProfileSchema, setMetadataProfileValue, saveMetadataProfile } from 'Store/Actions/settingsActions';
+import EditMetadataProfileModalContent from './EditMetadataProfileModalContent';
+
+function createPrimaryAlbumTypesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ (metadataProfile) => {
+ const primaryAlbumTypes = metadataProfile.item.primaryAlbumTypes;
+ if (!primaryAlbumTypes || !primaryAlbumTypes.value) {
+ return [];
+ }
+
+ return _.reduceRight(primaryAlbumTypes.value, (result, { allowed, albumType }) => {
+ if (allowed) {
+ result.push({
+ key: albumType.id,
+ value: albumType.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createSecondaryAlbumTypesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ (metadataProfile) => {
+ const secondaryAlbumTypes = metadataProfile.item.secondaryAlbumTypes;
+ if (!secondaryAlbumTypes || !secondaryAlbumTypes.value) {
+ return [];
+ }
+
+ return _.reduceRight(secondaryAlbumTypes.value, (result, { allowed, albumType }) => {
+ if (allowed) {
+ result.push({
+ key: albumType.id,
+ value: albumType.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createReleaseStatusesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ (metadataProfile) => {
+ const releaseStatuses = metadataProfile.item.releaseStatuses;
+ if (!releaseStatuses || !releaseStatuses.value) {
+ return [];
+ }
+
+ return _.reduceRight(releaseStatuses.value, (result, { allowed, releaseStatus }) => {
+ if (allowed) {
+ result.push({
+ key: releaseStatus.id,
+ value: releaseStatus.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector('metadataProfiles'),
+ createPrimaryAlbumTypesSelector(),
+ createSecondaryAlbumTypesSelector(),
+ createReleaseStatusesSelector(),
+ createProfileInUseSelector('metadataProfileId'),
+ (metadataProfile, primaryAlbumTypes, secondaryAlbumTypes, releaseStatuses, isInUse) => {
+ return {
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ releaseStatuses,
+ ...metadataProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMetadataProfileSchema,
+ setMetadataProfileValue,
+ saveMetadataProfile
+};
+
+class EditMetadataProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id && !this.props.isPopulated) {
+ this.props.fetchMetadataProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMetadataProfileValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMetadataProfile({ id: this.props.id });
+ }
+
+ onMetadataPrimaryTypeItemAllowedChange = (id, allowed) => {
+ const metadataProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(metadataProfile.primaryAlbumTypes.value, (i) => i.albumType.id === id);
+ item.allowed = allowed;
+
+ this.props.setMetadataProfileValue({
+ name: 'primaryAlbumTypes',
+ value: metadataProfile.primaryAlbumTypes.value
+ });
+ }
+
+ onMetadataSecondaryTypeItemAllowedChange = (id, allowed) => {
+ const metadataProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(metadataProfile.secondaryAlbumTypes.value, (i) => i.albumType.id === id);
+ item.allowed = allowed;
+
+ this.props.setMetadataProfileValue({
+ name: 'secondaryAlbumTypes',
+ value: metadataProfile.secondaryAlbumTypes.value
+ });
+ }
+
+ onMetadataReleaseStatusItemAllowedChange = (id, allowed) => {
+ const metadataProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(metadataProfile.releaseStatuses.value, (i) => i.releaseStatus.id === id);
+ item.allowed = allowed;
+
+ this.props.setMetadataProfileValue({
+ name: 'releaseStatuses',
+ value: metadataProfile.releaseStatuses.value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.primaryAlbumTypes) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditMetadataProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setMetadataProfileValue: PropTypes.func.isRequired,
+ fetchMetadataProfileSchema: PropTypes.func.isRequired,
+ saveMetadataProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css
new file mode 100644
index 000000000..880788343
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css
@@ -0,0 +1,31 @@
+.metadataProfile {
+ 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;
+}
+
+.albumTypes {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js
new file mode 100644
index 000000000..5943de616
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector';
+import styles from './MetadataProfile.css';
+
+class MetadataProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMetadataProfileModalOpen: false,
+ isDeleteMetadataProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMetadataProfilePress = () => {
+ this.setState({ isEditMetadataProfileModalOpen: true });
+ }
+
+ onEditMetadataProfileModalClose = () => {
+ this.setState({ isEditMetadataProfileModalOpen: false });
+ }
+
+ onDeleteMetadataProfilePress = () => {
+ this.setState({
+ isEditMetadataProfileModalOpen: false,
+ isDeleteMetadataProfileModalOpen: true
+ });
+ }
+
+ onDeleteMetadataProfileModalClose = () => {
+ this.setState({ isDeleteMetadataProfileModalOpen: false });
+ }
+
+ onConfirmDeleteMetadataProfile = () => {
+ this.props.onConfirmDeleteMetadataProfile(this.props.id);
+ }
+
+ onCloneMetadataProfilePress = () => {
+ const {
+ id,
+ onCloneMetadataProfilePress
+ } = this.props;
+
+ onCloneMetadataProfilePress(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ primaryAlbumTypes.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ return (
+
+ {item.albumType.name}
+
+ );
+ })
+ }
+
+
+
+ {
+ secondaryAlbumTypes.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ return (
+
+ {item.albumType.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+MetadataProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteMetadataProfile: PropTypes.func.isRequired,
+ onCloneMetadataProfilePress: PropTypes.func.isRequired
+
+};
+
+export default MetadataProfile;
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css
new file mode 100644
index 000000000..87ae2f44b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css
@@ -0,0 +1,21 @@
+.metadataProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addMetadataProfile {
+ composes: metadataProfile from '~./MetadataProfile.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/Metadata/MetadataProfiles.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js
new file mode 100644
index 000000000..feccfb77f
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.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 MetadataProfile from './MetadataProfile';
+import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector';
+import styles from './MetadataProfiles.css';
+
+class MetadataProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMetadataProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onCloneMetadataProfilePress = (id) => {
+ this.props.onCloneMetadataProfilePress(id);
+ this.setState({ isMetadataProfileModalOpen: true });
+ }
+
+ onEditMetadataProfilePress = () => {
+ this.setState({ isMetadataProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isMetadataProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteMetadataProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MetadataProfiles.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteMetadataProfile: PropTypes.func.isRequired,
+ onCloneMetadataProfilePress: PropTypes.func.isRequired
+};
+
+export default MetadataProfiles;
diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js
new file mode 100644
index 000000000..aa655cd85
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchMetadataProfiles, deleteMetadataProfile, cloneMetadataProfile } from 'Store/Actions/settingsActions';
+import MetadataProfiles from './MetadataProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.metadataProfiles,
+ (advancedSettings, metadataProfiles) => {
+ return {
+ advancedSettings,
+ ...metadataProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchMetadataProfiles: fetchMetadataProfiles,
+ dispatchDeleteMetadataProfile: deleteMetadataProfile,
+ dispatchCloneMetadataProfile: cloneMetadataProfile
+};
+
+class MetadataProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchMetadataProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteMetadataProfile = (id) => {
+ this.props.dispatchDeleteMetadataProfile({ id });
+ }
+
+ onCloneMetadataProfilePress = (id) => {
+ this.props.dispatchCloneMetadataProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadataProfilesConnector.propTypes = {
+ dispatchFetchMetadataProfiles: PropTypes.func.isRequired,
+ dispatchDeleteMetadataProfile: PropTypes.func.isRequired,
+ dispatchCloneMetadataProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js
new file mode 100644
index 000000000..3d5d42a9b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TypeItem.css';
+
+class PrimaryTypeItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ albumTypeId,
+ onMetadataPrimaryTypeItemAllowedChange
+ } = this.props;
+
+ onMetadataPrimaryTypeItemAllowedChange(albumTypeId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ );
+ }
+}
+
+PrimaryTypeItem.propTypes = {
+ albumTypeId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ onMetadataPrimaryTypeItemAllowedChange: PropTypes.func
+};
+
+export default PrimaryTypeItem;
diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js
new file mode 100644
index 000000000..487adbbd6
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js
@@ -0,0 +1,87 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import PrimaryTypeItem from './PrimaryTypeItem';
+import styles from './TypeItems.css';
+
+class PrimaryTypeItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ metadataProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ Primary Types
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ metadataProfileItems.map(({ allowed, albumType }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+ );
+ }
+}
+
+PrimaryTypeItems.propTypes = {
+ metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ formLabel: PropTypes.string
+};
+
+PrimaryTypeItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default PrimaryTypeItems;
diff --git a/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js
new file mode 100644
index 000000000..71fe7f76c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TypeItem.css';
+
+class ReleaseStatusItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ albumTypeId,
+ onMetadataReleaseStatusItemAllowedChange
+ } = this.props;
+
+ onMetadataReleaseStatusItemAllowedChange(albumTypeId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ );
+ }
+}
+
+ReleaseStatusItem.propTypes = {
+ albumTypeId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ onMetadataReleaseStatusItemAllowedChange: PropTypes.func
+};
+
+export default ReleaseStatusItem;
diff --git a/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js
new file mode 100644
index 000000000..31a24dff3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js
@@ -0,0 +1,87 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import ReleaseStatusItem from './ReleaseStatusItem';
+import styles from './TypeItems.css';
+
+class ReleaseStatusItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ metadataProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ Release Statuses
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ metadataProfileItems.map(({ allowed, releaseStatus }, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+ReleaseStatusItems.propTypes = {
+ metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ formLabel: PropTypes.string
+};
+
+ReleaseStatusItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default ReleaseStatusItems;
diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js
new file mode 100644
index 000000000..79995a920
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TypeItem.css';
+
+class SecondaryTypeItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ albumTypeId,
+ onMetadataSecondaryTypeItemAllowedChange
+ } = this.props;
+
+ onMetadataSecondaryTypeItemAllowedChange(albumTypeId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ );
+ }
+}
+
+SecondaryTypeItem.propTypes = {
+ albumTypeId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ onMetadataSecondaryTypeItemAllowedChange: PropTypes.func
+};
+
+export default SecondaryTypeItem;
diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js
new file mode 100644
index 000000000..3f46d710a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js
@@ -0,0 +1,87 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import SecondaryTypeItem from './SecondaryTypeItem';
+import styles from './TypeItems.css';
+
+class SecondaryTypeItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ metadataProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ Secondary Types
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ metadataProfileItems.map(({ allowed, albumType }, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+SecondaryTypeItems.propTypes = {
+ metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object),
+ formLabel: PropTypes.string
+};
+
+SecondaryTypeItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default SecondaryTypeItems;
diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItem.css b/frontend/src/Settings/Profiles/Metadata/TypeItem.css
new file mode 100644
index 000000000..908f3bde6
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/TypeItem.css
@@ -0,0 +1,25 @@
+.metadataProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
+.checkContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 7px;
+ margin-left: 8px;
+}
+
+.albumTypeName {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: 36px;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItems.css b/frontend/src/Settings/Profiles/Metadata/TypeItems.css
new file mode 100644
index 000000000..3bce22799
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Metadata/TypeItems.css
@@ -0,0 +1,6 @@
+.albumTypes {
+ margin-top: 10px;
+ /* TODO: This should consider the number of types in the list */
+ min-height: 200px;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js
new file mode 100644
index 000000000..63c90fca3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Profiles.js
@@ -0,0 +1,40 @@
+import React, { Component } from 'react';
+import { DndProvider } 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 MetadataProfilesConnector from './Metadata/MetadataProfilesConnector';
+import DelayProfilesConnector from './Delay/DelayProfilesConnector';
+import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector';
+
+// Only a single DragDrop Context can exist so it's done here to allow editing
+// quality profiles and reordering delay profiles to work.
+
+class Profiles extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default 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..b967b27e9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
@@ -0,0 +1,268 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Measure from 'Components/Measure';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import QualityProfileItems from './QualityProfileItems';
+import styles from './EditQualityProfileModalContent.css';
+
+const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
+
+class EditQualityProfileModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ headerHeight: 0,
+ bodyHeight: 0,
+ footerHeight: 0
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ headerHeight,
+ bodyHeight,
+ footerHeight
+ } = this.state;
+
+ if (
+ headerHeight > 0 &&
+ bodyHeight > 0 &&
+ footerHeight > 0 &&
+ (
+ headerHeight !== prevState.headerHeight ||
+ bodyHeight !== prevState.bodyHeight ||
+ footerHeight !== prevState.footerHeight
+ )
+ ) {
+ const padding = MODAL_BODY_PADDING * 2;
+
+ this.props.onContentHeightChange(
+ headerHeight + bodyHeight + footerHeight + padding
+ );
+ }
+ }
+
+ //
+ // Listeners
+
+ onHeaderMeasure = ({ height }) => {
+ if (height > this.state.headerHeight) {
+ this.setState({ headerHeight: height });
+ }
+ }
+
+ onBodyMeasure = ({ height }) => {
+
+ if (height > this.state.bodyHeight) {
+ this.setState({ bodyHeight: height });
+ }
+ }
+
+ onFooterMeasure = ({ height }) => {
+ if (height > this.state.footerHeight) {
+ this.setState({ footerHeight: height });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ editGroups,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ qualities,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteQualityProfilePress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ items
+ } = item;
+
+ return (
+
+
+
+ {id ? 'Edit Quality Profile' : 'Add Quality Profile'}
+
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
Unable to add a new quality profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+
+ }
+
+
+
+
+
+
+ {
+ id &&
+
+
+ Delete
+
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+ );
+ }
+}
+
+EditQualityProfileModalContent.propTypes = {
+ editGroups: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onCutoffChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onContentHeightChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteQualityProfilePress: PropTypes.func
+};
+
+export default EditQualityProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
new file mode 100644
index 000000000..2decf2198
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
@@ -0,0 +1,442 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions';
+import EditQualityProfileModalContent from './EditQualityProfileModalContent';
+
+function getQualityItemGroupId(qualityProfile) {
+ // Get items with an `id` and filter out null/undefined values
+ const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
+
+ return Math.max(1000, ...ids) + 1;
+}
+
+function parseIndex(index) {
+ const split = index.split('.');
+
+ if (split.length === 1) {
+ return [
+ null,
+ parseInt(split[0]) - 1
+ ];
+ }
+
+ return [
+ parseInt(split[0]) - 1,
+ parseInt(split[1]) - 1
+ ];
+}
+
+function createQualitiesSelector() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ (qualityProfile) => {
+ const items = qualityProfile.item.items;
+ if (!items || !items.value) {
+ return [];
+ }
+
+ return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
+ if (allowed) {
+ if (id) {
+ result.push({
+ key: id,
+ value: name
+ });
+ } else {
+ result.push({
+ key: quality.id,
+ value: quality.name
+ });
+ }
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector('qualityProfiles'),
+ createQualitiesSelector(),
+ createProfileInUseSelector('qualityProfileId'),
+ (qualityProfile, qualities, isInUse) => {
+ return {
+ qualities,
+ ...qualityProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ setQualityProfileValue,
+ saveQualityProfile
+};
+
+class EditQualityProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null,
+ editGroups: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id && !this.props.isPopulated) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Control
+
+ ensureCutoff = (qualityProfile) => {
+ const cutoff = qualityProfile.cutoff.value;
+
+ const cutoffItem = _.find(qualityProfile.items.value, (i) => {
+ if (!cutoff) {
+ return false;
+ }
+
+ return i.id === cutoff || (i.quality && i.quality.id === cutoff);
+ });
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
+ const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
+ let cutoffId = null;
+
+ if (firstAllowed) {
+ cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
+ }
+
+ this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setQualityProfileValue({ name, value });
+ }
+
+ onCutoffChange = ({ name, value }) => {
+ const id = parseInt(value);
+ const item = _.find(this.props.item.items.value, (i) => {
+ if (i.quality) {
+ return i.quality.id === id;
+ }
+
+ return i.id === id;
+ });
+
+ const cutoffId = item.quality ? item.quality.id : item.id;
+
+ this.props.setQualityProfileValue({ name, value: cutoffId });
+ }
+
+ onSavePress = () => {
+ this.props.saveQualityProfile({ id: this.props.id });
+ }
+
+ onQualityProfileItemAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
+
+ item.allowed = allowed;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onItemGroupAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(qualityProfile.items.value, (i) => i.id === id);
+
+ item.allowed = allowed;
+
+ // Update each item in the group (for consistency only)
+ item.items.forEach((i) => {
+ i.allowed = allowed;
+ });
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onItemGroupNameChange = (id, name) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+
+ group.name = name;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+ }
+
+ onCreateGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const item = _.find(items, (i) => i.quality && i.quality.id === id);
+ const index = items.indexOf(item);
+ const groupId = getQualityItemGroupId(qualityProfile);
+
+ const group = {
+ id: groupId,
+ name: item.quality.name,
+ allowed: item.allowed,
+ items: [
+ item
+ ]
+ };
+
+ // Add the group in the same location the quality item was in.
+ items.splice(index, 1, group);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onDeleteGroupPress = (id) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const group = _.find(items, (i) => i.id === id);
+ const index = items.indexOf(group);
+
+ // Add the items in the same location the group was in
+ items.splice(index, 1, ...group.items);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ onQualityProfileItemDragMove = (options) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex,
+ dropPosition
+ } = options;
+
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ if (
+ (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
+ (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
+ ) {
+ if (
+ this.state.dragQualityIndex != null &&
+ this.state.dropQualityIndex != null &&
+ this.state.dropPosition != null
+ ) {
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ }
+
+ return;
+ }
+
+ let adjustedDropQualityIndex = dropQualityIndex;
+
+ // Correct dragging out of a group to the position above
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex != null
+ ) {
+ // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting above outside a group
+ if (
+ dropPosition === 'above' &&
+ dragGroupIndex !== dropGroupIndex &&
+ dropGroupIndex == null
+ ) {
+ // Add 2 to the item index so it's entered in the correct place
+ adjustedDropQualityIndex = `${dropItemIndex + 2}`;
+ }
+
+ // Correct inserting below a quality within the same group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex != null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Add 1 to the group index leave the item index
+ adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
+ }
+
+ // Correct inserting below a quality outside a group (when moving a lower item)
+ if (
+ dropPosition === 'below' &&
+ dragGroupIndex === dropGroupIndex &&
+ dropGroupIndex == null &&
+ dragItemIndex < dropItemIndex
+ ) {
+ // Leave the item index so it's inserted below the item
+ adjustedDropQualityIndex = `${dropItemIndex}`;
+ }
+
+ if (
+ dragQualityIndex !== this.state.dragQualityIndex ||
+ adjustedDropQualityIndex !== this.state.dropQualityIndex ||
+ dropPosition !== this.state.dropPosition
+ ) {
+ this.setState({
+ dragQualityIndex,
+ dropQualityIndex: adjustedDropQualityIndex,
+ dropPosition
+ });
+ }
+ }
+
+ onQualityProfileItemDragEnd = (didDrop) => {
+ const {
+ dragQualityIndex,
+ dropQualityIndex
+ } = this.state;
+
+ if (didDrop && dropQualityIndex != null) {
+ const qualityProfile = _.cloneDeep(this.props.item);
+ const items = qualityProfile.items.value;
+ const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
+ const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
+
+ let item = null;
+ let dropGroup = null;
+
+ // Get the group before moving anything so we know the correct place to drop it.
+ if (dropGroupIndex != null) {
+ dropGroup = items[dropGroupIndex];
+ }
+
+ if (dragGroupIndex == null) {
+ item = items.splice(dragItemIndex, 1)[0];
+ } else {
+ const group = items[dragGroupIndex];
+ item = group.items.splice(dragItemIndex, 1)[0];
+
+ // If the group is now empty, destroy it.
+ if (!group.items.length) {
+ items.splice(dragGroupIndex, 1);
+ }
+ }
+
+ if (dropGroupIndex == null) {
+ items.splice(dropItemIndex, 0, item);
+ } else {
+ dropGroup.items.splice(dropItemIndex, 0, item);
+ }
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: items
+ });
+
+ this.ensureCutoff(qualityProfile);
+ }
+
+ this.setState({
+ dragQualityIndex: null,
+ dropQualityIndex: null,
+ dropPosition: null
+ });
+ }
+
+ onToggleEditGroupsMode = () => {
+ this.setState({ editGroups: !this.state.editGroups });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setQualityProfileValue: PropTypes.func.isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ saveQualityProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
new file mode 100644
index 000000000..2513577a1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
@@ -0,0 +1,38 @@
+.qualityProfile {
+ composes: card from '~Components/Card.css';
+
+ width: 300px;
+}
+
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.cloneButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
+.qualities {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+ pointer-events: all;
+}
+
+.tooltipLabel {
+ composes: label from '~Components/Label.css';
+
+ margin: 0;
+ border: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
new file mode 100644
index 000000000..f4a4ca414
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
@@ -0,0 +1,186 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfile.css';
+
+class QualityProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isEditQualityProfileModalOpen: true });
+ }
+
+ onEditQualityProfileModalClose = () => {
+ this.setState({ isEditQualityProfileModalOpen: false });
+ }
+
+ onDeleteQualityProfilePress = () => {
+ this.setState({
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: true
+ });
+ }
+
+ onDeleteQualityProfileModalClose = () => {
+ this.setState({ isDeleteQualityProfileModalOpen: false });
+ }
+
+ onConfirmDeleteQualityProfile = () => {
+ this.props.onConfirmDeleteQualityProfile(this.props.id);
+ }
+
+ onCloneQualityProfilePress = () => {
+ const {
+ id,
+ onCloneQualityProfilePress
+ } = this.props;
+
+ onCloneQualityProfilePress(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ upgradeAllowed,
+ cutoff,
+ items,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ items.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ if (item.quality) {
+ const isCutoff = upgradeAllowed && item.quality.id === cutoff;
+
+ return (
+
+ {item.quality.name}
+
+ );
+ }
+
+ const isCutoff = upgradeAllowed && item.id === cutoff;
+
+ return (
+
+ {item.name}
+
+ }
+ tooltip={
+
+ {
+ item.items.map((groupItem) => {
+ return (
+
+ {groupItem.quality.name}
+
+ );
+ })
+ }
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ />
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ upgradeAllowed: PropTypes.bool.isRequired,
+ cutoff: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfile;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
new file mode 100644
index 000000000..4e3f4aae5
--- /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..ac1b3ab80
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
@@ -0,0 +1,94 @@
+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
+ })
+};
+
+/* eslint-disable new-cap */
+export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview);
+/* eslint-enable new-cap */
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..a775ab427
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
@@ -0,0 +1,244 @@
+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
+};
+
+/* eslint-disable new-cap */
+export default DropTarget(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDropTarget,
+ collectDropTarget
+)(DragSource(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDragSource,
+ collectDragSource
+)(QualityProfileItemDragSource));
+/* eslint-enable new-cap */
+
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css
new file mode 100644
index 000000000..613a9b2d3
--- /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..c5bfba23d
--- /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..002e555a7
--- /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..437d152d2
--- /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..2e9f123c4
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
@@ -0,0 +1,106 @@
+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,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired,
+ onCloneQualityProfilePress: PropTypes.func.isRequired
+};
+
+export default QualityProfiles;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
new file mode 100644
index 000000000..c7596ad63
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions';
+import QualityProfiles from './QualityProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ return {
+ ...qualityProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchQualityProfiles: fetchQualityProfiles,
+ dispatchDeleteQualityProfile: deleteQualityProfile,
+ dispatchCloneQualityProfile: cloneQualityProfile
+};
+
+class QualityProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.dispatchFetchQualityProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteQualityProfile = (id) => {
+ this.props.dispatchDeleteQualityProfile({ id });
+ }
+
+ onCloneQualityProfilePress = (id) => {
+ this.props.dispatchCloneQualityProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfilesConnector.propTypes = {
+ dispatchFetchQualityProfiles: PropTypes.func.isRequired,
+ dispatchDeleteQualityProfile: PropTypes.func.isRequired,
+ dispatchCloneQualityProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js
new file mode 100644
index 000000000..5d0e74287
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector';
+
+function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditReleaseProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditReleaseProfileModal;
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js
new file mode 100644
index 000000000..89b605652
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditReleaseProfileModal from './EditReleaseProfileModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditReleaseProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'settings.releaseProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditReleaseProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css
new file mode 100644
index 000000000..a2b6014df
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js
new file mode 100644
index 000000000..b2423e791
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js
@@ -0,0 +1,161 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditReleaseProfileModalContent.css';
+
+// Tab, enter, and comma
+const tagInputDelimiters = [9, 13, 188];
+
+function EditReleaseProfileModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onModalClose,
+ onSavePress,
+ onDeleteReleaseProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ required,
+ ignored,
+ preferred,
+ includePreferredWhenRenaming,
+ tags
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Release Profile' : 'Add Release Profile'}
+
+
+
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditReleaseProfileModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteReleaseProfilePress: PropTypes.func
+};
+
+export default EditReleaseProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js
new file mode 100644
index 000000000..447bea3c7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js
@@ -0,0 +1,113 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setReleaseProfileValue, saveReleaseProfile } from 'Store/Actions/settingsActions';
+import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
+
+const newReleaseProfile = {
+ required: '',
+ ignored: '',
+ preferred: [],
+ includePreferredWhenRenaming: false,
+ tags: []
+};
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.releaseProfiles,
+ (id, releaseProfiles) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = releaseProfiles;
+
+ const profile = id ? _.find(items, { id }) : newReleaseProfile;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setReleaseProfileValue,
+ saveReleaseProfile
+};
+
+class EditReleaseProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newReleaseProfile).forEach((name) => {
+ this.props.setReleaseProfileValue({
+ name,
+ value: newReleaseProfile[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setReleaseProfileValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveReleaseProfile({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditReleaseProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setReleaseProfileValue: PropTypes.func.isRequired,
+ saveReleaseProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.css b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css
new file mode 100644
index 000000000..c1e2e59a2
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css
@@ -0,0 +1,11 @@
+.releaseProfile {
+ composes: card from '~Components/Card.css';
+
+ width: 290px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.js b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js
new file mode 100644
index 000000000..f1b03a68f
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import split from 'Utilities/String/split';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import TagList from 'Components/TagList';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
+import styles from './ReleaseProfile.css';
+
+class ReleaseProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditReleaseProfileModalOpen: false,
+ isDeleteReleaseProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditReleaseProfilePress = () => {
+ this.setState({ isEditReleaseProfileModalOpen: true });
+ }
+
+ onEditReleaseProfileModalClose = () => {
+ this.setState({ isEditReleaseProfileModalOpen: false });
+ }
+
+ onDeleteReleaseProfilePress = () => {
+ this.setState({
+ isEditReleaseProfileModalOpen: false,
+ isDeleteReleaseProfileModalOpen: true
+ });
+ }
+
+ onDeleteReleaseProfileModalClose= () => {
+ this.setState({ isDeleteReleaseProfileModalOpen: false });
+ }
+
+ onConfirmDeleteReleaseProfile = () => {
+ this.props.onConfirmDeleteReleaseProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ required,
+ ignored,
+ preferred,
+ tags,
+ tagList
+ } = this.props;
+
+ const {
+ isEditReleaseProfileModalOpen,
+ isDeleteReleaseProfileModalOpen
+ } = this.state;
+
+ return (
+
+
+ {
+ split(required).map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+ {item}
+
+ );
+ })
+ }
+
+
+
+ {
+ split(ignored).map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+ {item}
+
+ );
+ })
+ }
+
+
+
+ {
+ preferred.map((item) => {
+ const isPreferred = item.value >= 0;
+
+ return (
+
+ {item.key} {isPreferred && '+'}{item.value}
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ReleaseProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ required: PropTypes.string.isRequired,
+ ignored: PropTypes.string.isRequired,
+ preferred: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
+};
+
+ReleaseProfile.defaultProps = {
+ required: '',
+ ignored: '',
+ preferred: []
+};
+
+export default ReleaseProfile;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css
new file mode 100644
index 000000000..8f5a81252
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css
@@ -0,0 +1,20 @@
+.releaseProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addReleaseProfile {
+ composes: releaseProfile from '~./ReleaseProfile.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js
new file mode 100644
index 000000000..73c648a04
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import ReleaseProfile from './ReleaseProfile';
+import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
+import styles from './ReleaseProfiles.css';
+
+class ReleaseProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddReleaseProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddReleaseProfilePress = () => {
+ this.setState({ isAddReleaseProfileModalOpen: true });
+ }
+
+ onAddReleaseProfileModalClose = () => {
+ this.setState({ isAddReleaseProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ tagList,
+ onConfirmDeleteReleaseProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ReleaseProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
+};
+
+export default ReleaseProfiles;
diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js
new file mode 100644
index 000000000..dd4b41171
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchReleaseProfiles, deleteReleaseProfile } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import ReleaseProfiles from './ReleaseProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.releaseProfiles,
+ createTagsSelector(),
+ (releaseProfiles, tagList) => {
+ return {
+ ...releaseProfiles,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchReleaseProfiles,
+ deleteReleaseProfile
+};
+
+class ReleaseProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchReleaseProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteReleaseProfile = (id) => {
+ this.props.deleteReleaseProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ReleaseProfilesConnector.propTypes = {
+ fetchReleaseProfiles: PropTypes.func.isRequired,
+ deleteReleaseProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
new file mode 100644
index 000000000..134e14d0d
--- /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;
+}
+
+.kilobitsPerSecond {
+ 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..9c5258019
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
@@ -0,0 +1,264 @@
+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, tooltipPositions } from 'Helpers/Props';
+import Label from 'Components/Label';
+import NumberInput from 'Components/Form/NumberInput';
+import TextInput from 'Components/Form/TextInput';
+import Popover from 'Components/Tooltip/Popover';
+import QualityDefinitionLimits from './QualityDefinitionLimits';
+import styles from './QualityDefinition.css';
+
+const MIN = 0;
+const MAX = 1500;
+
+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 * 128;
+ const maxBytes = maxSize && maxSize * 128;
+
+ const minRate = `${formatBytes(minBytes, true)}/s`;
+ const maxRate = maxBytes ? `${formatBytes(maxBytes, true)}/s` : 'Unlimited';
+
+ return (
+
+
+ {quality.name}
+
+
+
+
+
+
+
+
+
+
+
+
{minRate}
+ }
+ title="Minimum Limits"
+ body={
+
+ }
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+
{maxRate}
+ }
+ title="Maximum Limits"
+ body={
+
+ }
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+
+ {
+ 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/QualityDefinitionLimits.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js
new file mode 100644
index 000000000..618fa1bd8
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+
+function QualityDefinitionLimits(props) {
+ const {
+ bytes,
+ message
+ } = props;
+
+ if (!bytes) {
+ return {message}
;
+ }
+
+ const twenty = formatBytes(bytes * 20 * 60);
+ const fourtyFive = formatBytes(bytes * 45 * 60);
+ const sixty = formatBytes(bytes * 60 * 60);
+
+ return (
+
+
20 Minutes: {twenty}
+
45 Minutes: {fourtyFive}
+
60 Minutes: {sixty}
+
+ );
+}
+
+QualityDefinitionLimits.propTypes = {
+ bytes: PropTypes.number,
+ message: PropTypes.string.isRequired
+};
+
+export default QualityDefinitionLimits;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css
new file mode 100644
index 000000000..9f4afbe0e
--- /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;
+}
+
+.kilobitsPerSecond {
+ 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..e7817de48
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
@@ -0,0 +1,72 @@
+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 ?
+
+ Kilobits Per Second
+
:
+ null
+ }
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ Limits are automatically adjusted for the album duration.
+
+
+
+
+ );
+ }
+}
+
+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..b7a36fe72
--- /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)(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..38e88e67f
--- /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..368b2c47b
--- /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, file management settings and root folders
+
+
+
+ Profiles
+
+
+
+ Quality, Metadata, Delay, and Release profiles
+
+
+
+ Quality
+
+
+
+ Quality sizes and naming
+
+
+
+ Indexers
+
+
+
+ Indexers and indexer options
+
+
+
+ Download Clients
+
+
+
+ Download clients, download handling and remote path mappings
+
+
+
+ Import Lists
+
+
+
+ Import Lists
+
+
+
+ Connect
+
+
+
+ Notifications, connections to media servers/players and custom scripts
+
+
+
+ Metadata
+
+
+
+ Create metadata files when tracks are imported or artist are refreshed
+
+
+
+ Tags
+
+
+
+ Manage artist, profile, restriction, and notification tags
+
+
+
+ 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..a47923c75
--- /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,
+ additionalButtons,
+ hasPendingLocation,
+ 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..d11136863
--- /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..5dd031837
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
@@ -0,0 +1,196 @@
+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,
+ artist,
+ delayProfiles,
+ importLists,
+ notifications,
+ releaseProfiles,
+ onModalClose,
+ onDeleteTagPress
+ } = props;
+
+ return (
+
+
+ Tag Details - {label}
+
+
+
+ {
+ !isTagUsed &&
+ Tag is not used and can be deleted
+ }
+
+ {
+ !!artist.length &&
+
+ {
+ artist.map((item) => {
+ return (
+
+ {item.artistName}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!delayProfiles.length &&
+
+ {
+ delayProfiles.map((item) => {
+ const {
+ id,
+ preferredProtocol,
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay
+ } = item;
+
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!notifications.length &&
+
+ {
+ notifications.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!importLists.length &&
+
+ {
+ importLists.map((item) => {
+ return (
+
+ {item.name}
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!releaseProfiles.length &&
+
+ {
+ releaseProfiles.map((item) => {
+ return (
+
+
+ {
+ split(item.required).map((r) => {
+ return (
+
+ {r}
+
+ );
+ })
+ }
+
+
+
+ {
+ split(item.ignored).map((i) => {
+ return (
+
+ {i}
+
+ );
+ })
+ }
+
+
+ );
+ })
+ }
+
+ }
+
+
+
+ {
+
+ Delete
+
+ }
+
+
+ Close
+
+
+
+ );
+}
+
+TagDetailsModalContent.propTypes = {
+ label: PropTypes.string.isRequired,
+ isTagUsed: PropTypes.bool.isRequired,
+ artist: PropTypes.arrayOf(PropTypes.object).isRequired,
+ delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ importLists: PropTypes.arrayOf(PropTypes.object).isRequired,
+ notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
+ releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteTagPress: PropTypes.func.isRequired
+};
+
+export default TagDetailsModalContent;
diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
new file mode 100644
index 000000000..18a3fb435
--- /dev/null
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
@@ -0,0 +1,71 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import TagDetailsModalContent from './TagDetailsModalContent';
+
+function findMatchingItems(ids, items) {
+ return items.filter((s) => {
+ return ids.includes(s.id);
+ });
+}
+
+function createMatchingArtistSelector() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ findMatchingItems
+ );
+}
+
+function createMatchingDelayProfilesSelector() {
+ return createSelector(
+ (state, { delayProfileIds }) => delayProfileIds,
+ (state) => state.settings.delayProfiles.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingImportListsSelector() {
+ return createSelector(
+ (state, { importListIds }) => importListIds,
+ (state) => state.settings.importLists.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingNotificationsSelector() {
+ return createSelector(
+ (state, { notificationIds }) => notificationIds,
+ (state) => state.settings.notifications.items,
+ findMatchingItems
+ );
+}
+
+function createMatchingReleaseProfilesSelector() {
+ return createSelector(
+ (state, { restrictionIds }) => restrictionIds,
+ (state) => state.settings.releaseProfiles.items,
+ findMatchingItems
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createMatchingArtistSelector(),
+ createMatchingDelayProfilesSelector(),
+ createMatchingImportListsSelector(),
+ createMatchingNotificationsSelector(),
+ createMatchingReleaseProfilesSelector(),
+ (artist, delayProfiles, importLists, notifications, releaseProfiles) => {
+ return {
+ artist,
+ delayProfiles,
+ importLists,
+ notifications,
+ releaseProfiles
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(TagDetailsModalContent);
diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css
new file mode 100644
index 000000000..ebf61e539
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.css
@@ -0,0 +1,12 @@
+.tag {
+ composes: card from '~Components/Card.css';
+
+ flex: 150px 0 1;
+}
+
+.label {
+ margin-bottom: 20px;
+ white-space: nowrap;
+ 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..b2aceb47e
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tag.js
@@ -0,0 +1,178 @@
+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,
+ importListIds,
+ notificationIds,
+ restrictionIds,
+ artistIds
+ } = this.props;
+
+ const {
+ isDetailsModalOpen,
+ isDeleteTagModalOpen
+ } = this.state;
+
+ const isTagUsed = !!(
+ delayProfileIds.length ||
+ importListIds.length ||
+ notificationIds.length ||
+ restrictionIds.length ||
+ artistIds.length
+ );
+
+ return (
+
+
+ {label}
+
+
+ {
+ isTagUsed &&
+
+ {
+ !!artistIds.length &&
+
+ {artistIds.length} artists
+
+ }
+
+ {
+ !!delayProfileIds.length &&
+
+ {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
+
+ }
+
+ {
+ !!importListIds.length &&
+
+ {importListIds.length} import list{importListIds.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,
+ importListIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onConfirmDeleteTag: PropTypes.func.isRequired
+};
+
+Tag.defaultProps = {
+ delayProfileIds: [],
+ importListIds: [],
+ notificationIds: [],
+ restrictionIds: [],
+ artistIds: []
+};
+
+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..8aed3c0a9
--- /dev/null
+++ b/frontend/src/Settings/Tags/Tags.js
@@ -0,0 +1,50 @@
+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 Link from 'Components/Link/Link';
+import styles from './Tags.css';
+
+function Tags(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ if (!items.length) {
+ return (
+ No tags have been added yet. Add tags to link artists with delay profiles, restrictions, or notifications. Click here to find out more about tags in Lidarr.
+ );
+ }
+
+ 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..70b727387
--- /dev/null
+++ b/frontend/src/Settings/Tags/TagsConnector.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 { fetchTagDetails } from 'Store/Actions/tagActions';
+import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles, fetchImportLists } 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,
+ dispatchFetchImportLists: fetchImportLists,
+ dispatchFetchNotifications: fetchNotifications,
+ dispatchFetchReleaseProfiles: fetchReleaseProfiles
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ dispatchFetchTagDetails,
+ dispatchFetchDelayProfiles,
+ dispatchFetchImportLists,
+ dispatchFetchNotifications,
+ dispatchFetchReleaseProfiles
+ } = this.props;
+
+ dispatchFetchTagDetails();
+ dispatchFetchDelayProfiles();
+ dispatchFetchImportLists();
+ dispatchFetchNotifications();
+ dispatchFetchReleaseProfiles();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ dispatchFetchTagDetails: PropTypes.func.isRequired,
+ dispatchFetchDelayProfiles: PropTypes.func.isRequired,
+ dispatchFetchImportLists: PropTypes.func.isRequired,
+ dispatchFetchNotifications: PropTypes.func.isRequired,
+ dispatchFetchReleaseProfiles: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/UI/UISettings.css b/frontend/src/Settings/UI/UISettings.css
new file mode 100644
index 000000000..2e6213823
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.css
@@ -0,0 +1,3 @@
+.columnGroup {
+ flex-direction: column;
+}
diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js
new file mode 100644
index 000000000..dc249487c
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.js
@@ -0,0 +1,241 @@
+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';
+import styles from './UISettings.css';
+
+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..e2dabe9c3
--- /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.ui' });
+ }
+
+ //
+ // 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..9fcc84361
--- /dev/null
+++ b/frontend/src/Shared/piwikCheck.js
@@ -0,0 +1,10 @@
+if (window.Lidarr.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/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/createBatchToggleAlbumMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js
new file mode 100644
index 000000000..04025b519
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js
@@ -0,0 +1,42 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import updateAlbums from 'Utilities/Album/updateAlbums';
+import getSectionState from 'Utilities/State/getSectionState';
+
+function createBatchToggleAlbumMonitoredHandler(section, fetchHandler) {
+ return function(getState, payload, dispatch) {
+ const {
+ albumIds,
+ monitored
+ } = payload;
+
+ const state = getSectionState(getState(), section, true);
+
+ dispatch(updateAlbums(section, state.items, albumIds, {
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/album/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ albumIds, monitored }),
+ dataType: 'json'
+ }).request;
+
+ promise.done(() => {
+ dispatch(updateAlbums(section, state.items, albumIds, {
+ isSaving: false,
+ monitored
+ }));
+
+ dispatch(fetchHandler());
+ });
+
+ promise.fail(() => {
+ dispatch(updateAlbums(section, state.items, albumIds, {
+ isSaving: false
+ }));
+ });
+ };
+}
+
+export default createBatchToggleAlbumMonitoredHandler;
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..a1f24bbbd
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
@@ -0,0 +1,33 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set } from '../baseActions';
+
+function createFetchSchemaHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSchemaFetching: true }));
+
+ const promise = createAjaxRequest({
+ url
+ }).request;
+
+ 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..a80ee1e45
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
@@ -0,0 +1,67 @@
+import _ from 'lodash';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest({
+ url,
+ data
+ }).request;
+
+ 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..5e4a2b386
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
@@ -0,0 +1,46 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest(ajaxOptions).request;
+
+ 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..e064b7e5a
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js
@@ -0,0 +1,43 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest({
+ url,
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ }).request;
+
+ 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..c0697f0e2
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
@@ -0,0 +1,80 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set, updateItem, removeItem } 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 = {}, removeStale = false) {
+ return function(getState, payload, dispatch) {
+ dispatch(set({ section, isSaving: true }));
+
+ const {
+ id,
+ queryParams = {},
+ ...otherPayload
+ } = payload;
+
+ const saveData = Array.isArray(id) ? id.map((x) => getProviderState({ id: x, ...otherPayload }, getState, section)) : 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.method = 'PUT';
+ if (!Array.isArray(id)) {
+ ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`;
+ }
+ }
+
+ const { request, abortRequest } = createAjaxRequest(ajaxOptions);
+
+ abortCurrentRequests[section] = abortRequest;
+
+ request.done((data) => {
+ if (!Array.isArray(data)) {
+ data = [data];
+ }
+
+ const toRemove = removeStale && Array.isArray(id) ? _.difference(id, _.map(data, 'id')) : [];
+
+ dispatch(batchActions(
+ data.map((item) => updateItem({ section, ...item })).concat(
+ toRemove.map((item) => removeItem({ section, id: item }))
+ ).concat(
+ 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..fcb0ad0bd
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/delayProfiles.js
@@ -0,0 +1,103 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest({
+ method: 'PUT',
+ url: `/delayprofile/reorder/${id}?${afterQueryParam}`
+ }).request;
+
+ 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/importListExclusions.js b/frontend/src/Store/Actions/Settings/importListExclusions.js
new file mode 100644
index 000000000..b584e9e28
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/importListExclusions.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.importListExclusions';
+
+//
+// Actions Types
+
+export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions';
+export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion';
+export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion';
+export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue';
+
+//
+// Action Creators
+
+export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS);
+export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION);
+export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION);
+
+export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_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_IMPORT_LIST_EXCLUSIONS]: createFetchHandler(section, '/importlistexclusion'),
+ [SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'),
+ [DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js
new file mode 100644
index 000000000..37c634554
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/importLists.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.importLists';
+
+//
+// Actions Types
+
+export const FETCH_IMPORT_LISTS = 'settings/importlists/fetchImportLists';
+export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importlists/fetchImportListSchema';
+export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importlists/selectImportListSchema';
+export const SET_IMPORT_LIST_VALUE = 'settings/importlists/setImportListValue';
+export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importlists/setImportListFieldValue';
+export const SAVE_IMPORT_LIST = 'settings/importlists/saveImportList';
+export const CANCEL_SAVE_IMPORT_LIST = 'settings/importlists/cancelSaveImportList';
+export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList';
+export const TEST_IMPORT_LIST = 'settings/importlists/testImportList';
+export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList';
+export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists';
+
+//
+// Action Creators
+
+export const fetchImportLists = createThunk(FETCH_IMPORT_LISTS);
+export const fetchImportListSchema = createThunk(FETCH_IMPORT_LIST_SCHEMA);
+export const selectImportListSchema = createAction(SELECT_IMPORT_LIST_SCHEMA);
+
+export const saveImportList = createThunk(SAVE_IMPORT_LIST);
+export const cancelSaveImportList = createThunk(CANCEL_SAVE_IMPORT_LIST);
+export const deleteImportList = createThunk(DELETE_IMPORT_LIST);
+export const testImportList = createThunk(TEST_IMPORT_LIST);
+export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
+export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS);
+
+export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setImportListFieldValue = createAction(SET_IMPORT_LIST_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_IMPORT_LISTS]: createFetchHandler(section, '/importlist'),
+ [FETCH_IMPORT_LIST_SCHEMA]: createFetchSchemaHandler(section, '/importlist/schema'),
+
+ [SAVE_IMPORT_LIST]: createSaveProviderHandler(section, '/importlist'),
+ [CANCEL_SAVE_IMPORT_LIST]: createCancelSaveProviderHandler(section),
+ [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'),
+ [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
+ [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section),
+ [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_IMPORT_LIST_VALUE]: createSetSettingValueReducer(section),
+ [SET_IMPORT_LIST_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_IMPORT_LIST_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.enableAutomaticAdd = true;
+ selectedSchema.shouldMonitor = 'entireArtist';
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js
new file mode 100644
index 000000000..53fb21651
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexerOptions.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.indexerOptions';
+
+//
+// Actions Types
+
+export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions';
+export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions';
+export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue';
+
+//
+// Action Creators
+
+export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS);
+export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS);
+export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'),
+ [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js
new file mode 100644
index 000000000..ddab7c154
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/indexers.js
@@ -0,0 +1,119 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.indexers';
+
+//
+// Actions Types
+
+export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers';
+export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema';
+export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema';
+export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue';
+export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue';
+export const SAVE_INDEXER = 'settings/indexers/saveIndexer';
+export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer';
+export const DELETE_INDEXER = 'settings/indexers/deleteIndexer';
+export const TEST_INDEXER = 'settings/indexers/testIndexer';
+export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
+export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
+
+//
+// Action Creators
+
+export const fetchIndexers = createThunk(FETCH_INDEXERS);
+export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA);
+export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA);
+
+export const saveIndexer = createThunk(SAVE_INDEXER);
+export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER);
+export const deleteIndexer = createThunk(DELETE_INDEXER);
+export const testIndexer = createThunk(TEST_INDEXER);
+export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
+export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
+
+export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ isTestingAll: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'),
+ [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'),
+
+ [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'),
+ [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section),
+ [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
+ [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
+ [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
+ [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_INDEXER_VALUE]: createSetSettingValueReducer(section),
+ [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
+
+ [SELECT_INDEXER_SCHEMA]: (state, { payload }) => {
+ return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.enableRss = selectedSchema.supportsRss;
+ selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch;
+ selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch;
+
+ return selectedSchema;
+ });
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/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/metadataProfiles.js b/frontend/src/Store/Actions/Settings/metadataProfiles.js
new file mode 100644
index 000000000..a553068d1
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/metadataProfiles.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.metadataProfiles';
+
+//
+// Actions Types
+
+export const FETCH_METADATA_PROFILES = 'settings/metadataProfiles/fetchMetadataProfiles';
+export const FETCH_METADATA_PROFILE_SCHEMA = 'settings/metadataProfiles/fetchMetadataProfileSchema';
+export const SAVE_METADATA_PROFILE = 'settings/metadataProfiles/saveMetadataProfile';
+export const DELETE_METADATA_PROFILE = 'settings/metadataProfiles/deleteMetadataProfile';
+export const SET_METADATA_PROFILE_VALUE = 'settings/metadataProfiles/setMetadataProfileValue';
+export const CLONE_METADATA_PROFILE = 'settings/metadataProfiles/cloneMetadataProfile';
+
+//
+// Action Creators
+
+export const fetchMetadataProfiles = createThunk(FETCH_METADATA_PROFILES);
+export const fetchMetadataProfileSchema = createThunk(FETCH_METADATA_PROFILE_SCHEMA);
+export const saveMetadataProfile = createThunk(SAVE_METADATA_PROFILE);
+export const deleteMetadataProfile = createThunk(DELETE_METADATA_PROFILE);
+
+export const setMetadataProfileValue = createAction(SET_METADATA_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneMetadataProfile = createAction(CLONE_METADATA_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_METADATA_PROFILES]: createFetchHandler(section, '/metadataprofile'),
+ [FETCH_METADATA_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/metadataprofile/schema'),
+ [SAVE_METADATA_PROFILE]: createSaveProviderHandler(section, '/metadataprofile'),
+ [DELETE_METADATA_PROFILE]: createRemoveItemHandler(section, '/metadataprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_METADATA_PROFILE_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_METADATA_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/metadataProvider.js b/frontend/src/Store/Actions/Settings/metadataProvider.js
new file mode 100644
index 000000000..32ebd88a0
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/metadataProvider.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.metadataProvider';
+
+//
+// Actions Types
+
+export const FETCH_METADATA_PROVIDER = 'settings/metadataProvider/fetchMetadataProvider';
+export const SET_METADATA_PROVIDER_VALUE = 'settings/metadataProvider/setMetadataProviderValue';
+export const SAVE_METADATA_PROVIDER = 'settings/metadataProvider/saveMetadataProvider';
+
+//
+// Action Creators
+
+export const fetchMetadataProvider = createThunk(FETCH_METADATA_PROVIDER);
+export const saveMetadataProvider = createThunk(SAVE_METADATA_PROVIDER);
+export const setMetadataProviderValue = createAction(SET_METADATA_PROVIDER_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_METADATA_PROVIDER]: createFetchHandler(section, '/config/metadataProvider'),
+ [SAVE_METADATA_PROVIDER]: createSaveHandler(section, '/config/metadataProvider')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_METADATA_PROVIDER_VALUE]: createSetSettingValueReducer(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..d937b8f2e
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/namingExamples.js
@@ -0,0 +1,79 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest({
+ url: '/config/naming/examples',
+ data: Object.assign({}, naming.item, naming.pendingChanges)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {}
+
+};
diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js
new file mode 100644
index 000000000..9a267a930
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/notifications.js
@@ -0,0 +1,119 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import 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.onReleaseImport = selectedSchema.supportsOnReleaseImport;
+ selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
+ selectedSchema.onRename = selectedSchema.supportsOnRename;
+ selectedSchema.onHealthIssue = selectedSchema.supportsOnHealthIssue;
+ selectedSchema.onDownloadFailure = selectedSchema.supportsOnDownloadFailure;
+ selectedSchema.onImportFailure = selectedSchema.supportsOnImportFailure;
+ selectedSchema.onTrackRetag = selectedSchema.supportsOnTrackRetag;
+
+ 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..ef5d0a757
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js
@@ -0,0 +1,135 @@
+import _ from 'lodash';
+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 { 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 = createAjaxRequest({
+ method: 'PUT',
+ url: '/qualityDefinition/update',
+ data: JSON.stringify(upatedDefinitions)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ }),
+
+ update({ section, data }),
+ clearPendingChanges({ section })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) {
+ const { id, name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = _.cloneDeep(newState.pendingChanges);
+
+ const pendingState = newState.pendingChanges[id] || {};
+ const currentValue = _.find(newState.items, { id })[name];
+
+ if (currentValue === value) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = value;
+ }
+
+ if (_.isEmpty(pendingState)) {
+ delete newState.pendingChanges[id];
+ } else {
+ newState.pendingChanges[id] = pendingState;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js
new file mode 100644
index 000000000..6fdc204a0
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js
@@ -0,0 +1,97 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.qualityProfiles';
+
+//
+// Actions Types
+
+export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles';
+export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema';
+export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile';
+export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile';
+export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue';
+export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile';
+
+//
+// Action Creators
+
+export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES);
+export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA);
+export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE);
+export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE);
+
+export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE);
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSchemaFetching: false,
+ isSchemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'),
+ [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'),
+ [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'),
+ [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section),
+
+ [CLONE_QUALITY_PROFILE]: function(state, { payload }) {
+ const id = payload.id;
+ const newState = getSectionState(state, section);
+ const item = newState.items.find((i) => i.id === id);
+ const pendingChanges = { ...item, id: 0 };
+ delete pendingChanges.id;
+
+ pendingChanges.name = `${pendingChanges.name} - Copy`;
+ newState.pendingChanges = pendingChanges;
+
+ return updateSectionState(state, section, newState);
+ }
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/releaseProfiles.js b/frontend/src/Store/Actions/Settings/releaseProfiles.js
new file mode 100644
index 000000000..339e732f6
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/releaseProfiles.js
@@ -0,0 +1,71 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.releaseProfiles';
+
+//
+// Actions Types
+
+export const FETCH_RELEASE_PROFILES = 'settings/releaseProfiles/fetchReleaseProfiles';
+export const SAVE_RELEASE_PROFILE = 'settings/releaseProfiles/saveReleaseProfile';
+export const DELETE_RELEASE_PROFILE = 'settings/releaseProfiles/deleteReleaseProfile';
+export const SET_RELEASE_PROFILE_VALUE = 'settings/releaseProfiles/setReleaseProfileValue';
+
+//
+// Action Creators
+
+export const fetchReleaseProfiles = createThunk(FETCH_RELEASE_PROFILES);
+export const saveReleaseProfile = createThunk(SAVE_RELEASE_PROFILE);
+export const deleteReleaseProfile = createThunk(DELETE_RELEASE_PROFILE);
+
+export const setReleaseProfileValue = createAction(SET_RELEASE_PROFILE_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'),
+
+ [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'),
+
+ [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js
new file mode 100644
index 000000000..3cfcc7f1f
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js
@@ -0,0 +1,69 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
+import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
+
+//
+// Variables
+
+const section = 'settings.remotePathMappings';
+
+//
+// Actions Types
+
+export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings';
+export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping';
+export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping';
+export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue';
+
+//
+// Action Creators
+
+export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS);
+export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING);
+export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING);
+
+export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'),
+ [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'),
+ [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js
new file mode 100644
index 000000000..97d7223fd
--- /dev/null
+++ b/frontend/src/Store/Actions/Settings/ui.js
@@ -0,0 +1,64 @@
+import { createAction } from 'redux-actions';
+import { createThunk } from 'Store/thunks';
+import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
+import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
+import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
+
+//
+// Variables
+
+const section = 'settings.ui';
+
+//
+// Actions Types
+
+export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings';
+export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE';
+export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS';
+
+//
+// Action Creators
+
+export const fetchUISettings = createThunk(FETCH_UI_SETTINGS);
+export const saveUISettings = createThunk(SAVE_UI_SETTINGS);
+export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Details
+
+export default {
+
+ //
+ // State
+
+ defaultState: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ //
+ // Action Handlers
+
+ actionHandlers: {
+ [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'),
+ [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui')
+ },
+
+ //
+ // Reducers
+
+ reducers: {
+ [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section)
+ }
+
+};
diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js
new file mode 100644
index 000000000..dcb13d86d
--- /dev/null
+++ b/frontend/src/Store/Actions/actionTypes.js
@@ -0,0 +1,16 @@
+//
+// 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';
diff --git a/frontend/src/Store/Actions/addArtistActions.js b/frontend/src/Store/Actions/addArtistActions.js
new file mode 100644
index 000000000..44496f6d5
--- /dev/null
+++ b/frontend/src/Store/Actions/addArtistActions.js
@@ -0,0 +1,179 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import monitorOptions from 'Utilities/Artist/monitorOptions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getNewArtist from 'Utilities/Artist/getNewArtist';
+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 = 'addArtist';
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isAdding: false,
+ isAdded: false,
+ addError: null,
+ items: [],
+
+ defaults: {
+ rootFolderPath: '',
+ monitor: monitorOptions[0].key,
+ qualityProfileId: 0,
+ metadataProfileId: 0,
+ albumFolder: true,
+ tags: []
+ }
+};
+
+export const persistState = [
+ 'addArtist.defaults'
+];
+
+//
+// Actions Types
+
+export const LOOKUP_ARTIST = 'addArtist/lookupArtist';
+export const ADD_ARTIST = 'addArtist/addArtist';
+export const SET_ADD_ARTIST_VALUE = 'addArtist/setAddArtistValue';
+export const CLEAR_ADD_ARTIST = 'addArtist/clearAddArtist';
+export const SET_ADD_ARTIST_DEFAULT = 'addArtist/setAddArtistDefault';
+
+//
+// Action Creators
+
+export const lookupArtist = createThunk(LOOKUP_ARTIST);
+export const addArtist = createThunk(ADD_ARTIST);
+export const clearAddArtist = createAction(CLEAR_ADD_ARTIST);
+export const setAddArtistDefault = createAction(SET_ADD_ARTIST_DEFAULT);
+
+export const setAddArtistValue = createAction(SET_ADD_ARTIST_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [LOOKUP_ARTIST]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ if (abortCurrentRequest) {
+ abortCurrentRequest();
+ }
+
+ const { request, abortRequest } = createAjaxRequest({
+ url: '/artist/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_ARTIST]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isAdding: true }));
+
+ const foreignArtistId = payload.foreignArtistId;
+ const items = getState().addArtist.items;
+ const newArtist = getNewArtist(_.cloneDeep(_.find(items, { foreignArtistId })), payload);
+
+ const promise = createAjaxRequest({
+ url: '/artist',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(newArtist)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section: 'artist', ...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_ARTIST_VALUE]: createSetSettingValueReducer(section),
+
+ [SET_ADD_ARTIST_DEFAULT]: function(state, { payload }) {
+ const newState = getSectionState(state, section);
+
+ newState.defaults = {
+ ...newState.defaults,
+ ...payload
+ };
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [CLEAR_ADD_ARTIST]: function(state) {
+ const {
+ defaults,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/albumActions.js b/frontend/src/Store/Actions/albumActions.js
new file mode 100644
index 000000000..9af72a9f1
--- /dev/null
+++ b/frontend/src/Store/Actions/albumActions.js
@@ -0,0 +1,256 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import albumEntities from 'Album/albumEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import { updateItem } from './baseActions';
+
+//
+// Variables
+
+export const section = 'albums';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+ pendingChanges: {},
+ sortPredicates: {
+ rating: function(item) {
+ return item.ratings.value;
+ }
+ },
+
+ columns: [
+ {
+ name: 'monitored',
+ columnLabel: 'Monitored',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: 'Release Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'secondaryTypes',
+ label: 'Secondary Types',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'mediumCount',
+ label: 'Media Count',
+ isVisible: false
+ },
+ {
+ name: 'trackCount',
+ label: 'Track Count',
+ isVisible: false
+ },
+ {
+ name: 'duration',
+ label: 'Duration',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'rating',
+ label: 'Rating',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'albums.sortKey',
+ 'albums.sortDirection',
+ 'albums.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_ALBUMS = 'albums/fetchAlbums';
+export const SET_ALBUMS_SORT = 'albums/setAlbumsSort';
+export const SET_ALBUMS_TABLE_OPTION = 'albums/setAlbumsTableOption';
+export const CLEAR_ALBUMS = 'albums/clearAlbums';
+export const SET_ALBUM_VALUE = 'albums/setAlbumValue';
+export const SAVE_ALBUM = 'albums/saveAlbum';
+export const TOGGLE_ALBUM_MONITORED = 'albums/toggleAlbumMonitored';
+export const TOGGLE_ALBUMS_MONITORED = 'albums/toggleAlbumsMonitored';
+
+//
+// Action Creators
+
+export const fetchAlbums = createThunk(FETCH_ALBUMS);
+export const setAlbumsSort = createAction(SET_ALBUMS_SORT);
+export const setAlbumsTableOption = createAction(SET_ALBUMS_TABLE_OPTION);
+export const clearAlbums = createAction(CLEAR_ALBUMS);
+export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED);
+export const toggleAlbumsMonitored = createThunk(TOGGLE_ALBUMS_MONITORED);
+
+export const saveAlbum = createThunk(SAVE_ALBUM);
+
+export const setAlbumValue = createAction(SET_ALBUM_VALUE, (payload) => {
+ return {
+ section: 'albums',
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_ALBUMS]: createFetchHandler(section, '/album'),
+ [SAVE_ALBUM]: createSaveProviderHandler(section, '/album'),
+
+ [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ albumId,
+ albumEntity = albumEntities.ALBUMS,
+ monitored
+ } = payload;
+
+ const albumSection = _.last(albumEntity.split('.'));
+
+ dispatch(updateItem({
+ id: albumId,
+ section: albumSection,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: `/album/${albumId}`,
+ method: 'PUT',
+ data: JSON.stringify({ monitored }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id: albumId,
+ section: albumSection,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id: albumId,
+ section: albumSection,
+ isSaving: false
+ }));
+ });
+ },
+
+ [TOGGLE_ALBUMS_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ albumIds,
+ albumEntity = albumEntities.ALBUMS,
+ monitored
+ } = payload;
+
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: albumEntity,
+ isSaving: true
+ });
+ })
+ ));
+
+ const promise = createAjaxRequest({
+ url: '/album/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ albumIds, monitored }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: albumEntity,
+ isSaving: false,
+ monitored
+ });
+ })
+ ));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: albumEntity,
+ isSaving: false
+ });
+ })
+ ));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(section),
+
+ [SET_ALBUMS_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [SET_ALBUM_VALUE]: createSetSettingValueReducer(section),
+
+ [CLEAR_ALBUMS]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/albumHistoryActions.js b/frontend/src/Store/Actions/albumHistoryActions.js
new file mode 100644
index 000000000..a0c832784
--- /dev/null
+++ b/frontend/src/Store/Actions/albumHistoryActions.js
@@ -0,0 +1,112 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { createThunk, handleThunks } from 'Store/thunks';
+import { sortDirections } from 'Helpers/Props';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'albumHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ALBUM_HISTORY = 'albumHistory/fetchAlbumHistory';
+export const CLEAR_ALBUM_HISTORY = 'albumHistory/clearAlbumHistory';
+export const ALBUM_HISTORY_MARK_AS_FAILED = 'albumHistory/albumHistoryMarkAsFailed';
+
+//
+// Action Creators
+
+export const fetchAlbumHistory = createThunk(FETCH_ALBUM_HISTORY);
+export const clearAlbumHistory = createAction(CLEAR_ALBUM_HISTORY);
+export const albumHistoryMarkAsFailed = createThunk(ALBUM_HISTORY_MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ALBUM_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const queryParams = {
+ pageSize: 1000,
+ page: 1,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ albumId: payload.albumId
+ };
+
+ const promise = createAjaxRequest({
+ url: '/history',
+ data: queryParams
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data: data.records }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ },
+
+ [ALBUM_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const {
+ historyId,
+ albumId
+ } = payload;
+
+ const promise = createAjaxRequest({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id: historyId
+ }
+ }).request;
+
+ promise.done(() => {
+ dispatch(fetchAlbumHistory({ albumId }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_ALBUM_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js
new file mode 100644
index 000000000..5225c27cf
--- /dev/null
+++ b/frontend/src/Store/Actions/albumStudioActions.js
@@ -0,0 +1,164 @@
+import { createAction } from 'redux-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set } from './baseActions';
+import { fetchAlbums } from './albumActions';
+import { filters, filterPredicates } from './artistActions';
+
+//
+// Variables
+
+export const section = 'albumStudio';
+
+//
+// State
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortName',
+ 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.ARTIST_STATUS
+ },
+ {
+ name: 'artistType',
+ label: 'Artist Type',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'metadataProfileId',
+ label: 'Metadata Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.METADATA_PROFILE
+ },
+ {
+ name: 'rootFolderPath',
+ label: 'Root Folder Path',
+ type: filterBuilderTypes.EXACT
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ]
+};
+
+export const persistState = [
+ 'albumStudio.sortKey',
+ 'albumStudio.sortDirection',
+ 'albumStudio.selectedFilterKey',
+ 'albumStudio.customFilters'
+];
+
+//
+// Actions Types
+
+export const SET_ALBUM_STUDIO_SORT = 'albumStudio/setAlbumStudioSort';
+export const SET_ALBUM_STUDIO_FILTER = 'albumStudio/setAlbumStudioFilter';
+export const SAVE_ALBUM_STUDIO = 'albumStudio/saveAlbumStudio';
+
+//
+// Action Creators
+
+export const setAlbumStudioSort = createAction(SET_ALBUM_STUDIO_SORT);
+export const setAlbumStudioFilter = createAction(SET_ALBUM_STUDIO_FILTER);
+export const saveAlbumStudio = createThunk(SAVE_ALBUM_STUDIO);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [SAVE_ALBUM_STUDIO]: function(getState, payload, dispatch) {
+ const {
+ artistIds,
+ monitored,
+ monitor
+ } = payload;
+
+ const artist = [];
+
+ artistIds.forEach((id) => {
+ const artistToUpdate = { id };
+
+ if (payload.hasOwnProperty('monitored')) {
+ artistToUpdate.monitored = monitored;
+ }
+
+ artist.push(artistToUpdate);
+ });
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/albumStudio',
+ method: 'POST',
+ data: JSON.stringify({
+ artist,
+ monitoringOptions: { monitor }
+ }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(fetchAlbums());
+
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ALBUM_STUDIO_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_ALBUM_STUDIO_FILTER]: createSetClientSideCollectionFilterReducer(section)
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js
new file mode 100644
index 000000000..b4b84a455
--- /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.Lidarr.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/artistActions.js b/frontend/src/Store/Actions/artistActions.js
new file mode 100644
index 000000000..a47dfe272
--- /dev/null
+++ b/frontend/src/Store/Actions/artistActions.js
@@ -0,0 +1,336 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = 'artist';
+
+export const filters = [
+ {
+ key: 'all',
+ label: 'All',
+ filters: []
+ },
+ {
+ key: 'monitored',
+ label: 'Monitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: 'Unmonitored Only',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'continuing',
+ label: 'Continuing Only',
+ filters: [
+ {
+ key: 'status',
+ value: 'continuing',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'ended',
+ label: 'Ended Only',
+ filters: [
+ {
+ key: 'status',
+ value: 'ended',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'missing',
+ label: 'Missing Tracks',
+ filters: [
+ {
+ key: 'missing',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+];
+
+export const filterPredicates = {
+ missing: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.trackCount - statistics.trackFileCount > 0;
+ },
+
+ nextAlbum: function(item, filterValue, type) {
+ return dateFilterPredicate(item.nextAlbum, filterValue, type);
+ },
+
+ lastAlbum: function(item, filterValue, type) {
+ return dateFilterPredicate(item.lastAlbum, filterValue, type);
+ },
+
+ added: function(item, filterValue, type) {
+ return dateFilterPredicate(item.added, filterValue, type);
+ },
+
+ ratings: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+
+ return predicate(item.ratings.value * 10, filterValue);
+ },
+
+ albumCount: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+ const albumCount = item.statistics ? item.statistics.albumCount : 0;
+
+ return predicate(albumCount, filterValue);
+ },
+
+ sizeOnDisk: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+ const sizeOnDisk = item.statistics ? item.statistics.sizeOnDisk : 0;
+
+ return predicate(sizeOnDisk, filterValue);
+ }
+};
+
+export const sortPredicates = {
+ status: function(item) {
+ let result = 0;
+
+ if (item.monitored) {
+ result += 2;
+ }
+
+ if (item.status === 'continuing') {
+ result++;
+ }
+
+ return result;
+ }
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ pendingChanges: {}
+};
+
+//
+// Actions Types
+
+export const FETCH_ARTIST = 'artist/fetchArtist';
+export const SET_ARTIST_VALUE = 'artist/setArtistValue';
+export const SAVE_ARTIST = 'artist/saveArtist';
+export const DELETE_ARTIST = 'artist/deleteArtist';
+
+export const TOGGLE_ARTIST_MONITORED = 'artist/toggleArtistMonitored';
+export const TOGGLE_ALBUM_MONITORED = 'artist/toggleAlbumMonitored';
+
+//
+// Action Creators
+
+export const fetchArtist = createThunk(FETCH_ARTIST);
+export const saveArtist = createThunk(SAVE_ARTIST, (payload) => {
+ const newPayload = {
+ ...payload
+ };
+
+ if (payload.moveFiles) {
+ newPayload.queryParams = {
+ moveFiles: true
+ };
+ }
+
+ delete newPayload.moveFiles;
+
+ return newPayload;
+});
+
+export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => {
+ return {
+ ...payload,
+ queryParams: {
+ deleteFiles: payload.deleteFiles,
+ addImportListExclusion: payload.addImportListExclusion
+ }
+ };
+});
+
+export const toggleArtistMonitored = createThunk(TOGGLE_ARTIST_MONITORED);
+export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED);
+
+export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Helpers
+
+function getSaveAjaxOptions({ ajaxOptions, payload }) {
+ if (payload.moveFolder) {
+ ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`;
+ }
+
+ return ajaxOptions;
+}
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ARTIST]: createFetchHandler(section, '/artist'),
+ [SAVE_ARTIST]: createSaveProviderHandler(section, '/artist', { getAjaxOptions: getSaveAjaxOptions }),
+ [DELETE_ARTIST]: createRemoveItemHandler(section, '/artist'),
+
+ [TOGGLE_ARTIST_MONITORED]: (getState, payload, dispatch) => {
+ const {
+ artistId: id,
+ monitored
+ } = payload;
+
+ const artist = _.find(getState().artist.items, { id });
+
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: `/artist/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...artist,
+ monitored
+ }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false
+ }));
+ });
+ },
+
+ [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) {
+ const {
+ artistId: id,
+ seasonNumber,
+ monitored
+ } = payload;
+
+ const artist = _.find(getState().artist.items, { id });
+ const seasons = _.cloneDeep(artist.seasons);
+ const season = _.find(seasons, { seasonNumber });
+
+ season.isSaving = true;
+
+ dispatch(updateItem({
+ id,
+ section,
+ seasons
+ }));
+
+ season.monitored = monitored;
+
+ const promise = createAjaxRequest({
+ url: `/artist/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...artist,
+ seasons
+ }),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ const albums = _.filter(getState().albums.items, { artistId: id, seasonNumber });
+
+ dispatch(batchActions([
+ updateItem({
+ id,
+ section,
+ ...data
+ }),
+
+ ...albums.map((album) => {
+ return updateItem({
+ id: album.id,
+ section: 'albums',
+ monitored
+ });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ seasons: artist.seasons
+ }));
+ });
+ }
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ARTIST_VALUE]: createSetSettingValueReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js
new file mode 100644
index 000000000..238419df0
--- /dev/null
+++ b/frontend/src/Store/Actions/artistEditorActions.js
@@ -0,0 +1,187 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 './artistActions';
+
+//
+// Variables
+
+export const section = 'artistEditor';
+
+//
+// State
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortName',
+ 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.ARTIST_STATUS
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'metadataProfileId',
+ label: 'Metadata Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.METADATA_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 = [
+ 'artistEditor.sortKey',
+ 'artistEditor.sortDirection',
+ 'artistEditor.selectedFilterKey',
+ 'artistEditor.customFilters'
+];
+
+//
+// Actions Types
+
+export const SET_ARTIST_EDITOR_SORT = 'artistEditor/setArtistEditorSort';
+export const SET_ARTIST_EDITOR_FILTER = 'artistEditor/setArtistEditorFilter';
+export const SAVE_ARTIST_EDITOR = 'artistEditor/saveArtistEditor';
+export const BULK_DELETE_ARTIST = 'artistEditor/bulkDeleteArtist';
+
+//
+// Action Creators
+
+export const setArtistEditorSort = createAction(SET_ARTIST_EDITOR_SORT);
+export const setArtistEditorFilter = createAction(SET_ARTIST_EDITOR_FILTER);
+export const saveArtistEditor = createThunk(SAVE_ARTIST_EDITOR);
+export const bulkDeleteArtist = createThunk(BULK_DELETE_ARTIST);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [SAVE_ARTIST_EDITOR]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/artist/editor',
+ method: 'PUT',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ ...data.map((artist) => {
+ return updateItem({
+ id: artist.id,
+ section: 'artist',
+ ...artist
+ });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ },
+
+ [BULK_DELETE_ARTIST]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isDeleting: true
+ }));
+
+ const promise = createAjaxRequest({
+ url: '/artist/editor',
+ method: 'DELETE',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ }).request;
+
+ promise.done(() => {
+ // SignalR will take care of removing the artist 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_ARTIST_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/artistHistoryActions.js b/frontend/src/Store/Actions/artistHistoryActions.js
new file mode 100644
index 000000000..237004ae3
--- /dev/null
+++ b/frontend/src/Store/Actions/artistHistoryActions.js
@@ -0,0 +1,104 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'artistHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ARTIST_HISTORY = 'artistHistory/fetchArtistHistory';
+export const CLEAR_ARTIST_HISTORY = 'artistHistory/clearArtistHistory';
+export const ARTIST_HISTORY_MARK_AS_FAILED = 'artistHistory/artistHistoryMarkAsFailed';
+
+//
+// Action Creators
+
+export const fetchArtistHistory = createThunk(FETCH_ARTIST_HISTORY);
+export const clearArtistHistory = createAction(CLEAR_ARTIST_HISTORY);
+export const artistHistoryMarkAsFailed = createThunk(ARTIST_HISTORY_MARK_AS_FAILED);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_ARTIST_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = createAjaxRequest({
+ url: '/history/artist',
+ data: payload
+ }).request;
+
+ 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
+ }));
+ });
+ },
+
+ [ARTIST_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
+ const {
+ historyId,
+ artistId,
+ albumId
+ } = payload;
+
+ const promise = createAjaxRequest({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id: historyId
+ }
+ }).request;
+
+ promise.done(() => {
+ dispatch(fetchArtistHistory({ artistId, albumId }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_ARTIST_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
+
diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js
new file mode 100644
index 000000000..427806b2b
--- /dev/null
+++ b/frontend/src/Store/Actions/artistIndexActions.js
@@ -0,0 +1,430 @@
+import { createAction } from 'redux-actions';
+import sortByName from 'Utilities/Array/sortByName';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, 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 './artistActions';
+
+//
+// Variables
+
+export const section = 'artistIndex';
+
+//
+// State
+
+export const defaultState = {
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortName',
+ secondarySortDirection: sortDirections.ASCENDING,
+ view: 'posters',
+
+ posterOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: true,
+ showMonitored: true,
+ showQualityProfile: true,
+ showSearchAction: false
+ },
+
+ bannerOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: false,
+ showMonitored: true,
+ showQualityProfile: true,
+ showSearchAction: false
+ },
+
+ overviewOptions: {
+ detailedProgressBar: false,
+ size: 'medium',
+ showMonitored: true,
+ showQualityProfile: true,
+ showLastAlbum: false,
+ showAdded: false,
+ showAlbumCount: true,
+ showPath: false,
+ showSizeOnDisk: false,
+ showSearchAction: false
+ },
+
+ tableOptions: {
+ showBanners: false,
+ showSearchAction: false
+ },
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'sortName',
+ label: 'Artist Name',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'artistType',
+ label: 'Type',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'metadataProfileId',
+ label: 'Metadata Profile',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'nextAlbum',
+ label: 'Next Album',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'lastAlbum',
+ label: 'Last Album',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'added',
+ label: 'Added',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'albumCount',
+ label: 'Albums',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackProgress',
+ label: 'Tracks',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackCount',
+ label: 'Track Count',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'sizeOnDisk',
+ label: 'Size on Disk',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'genres',
+ label: 'Genres',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'ratings',
+ label: 'Rating',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ sortPredicates: {
+ ...sortPredicates,
+
+ trackProgress: function(item) {
+ const { statistics = {} } = item;
+
+ const {
+ trackCount = 0,
+ trackFileCount
+ } = statistics;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ return progress + trackCount / 1000000;
+ },
+
+ nextAlbum: function(item) {
+ if (item.nextAlbum) {
+ return item.nextAlbum.releaseDate;
+ }
+ return '1/1/1000';
+ },
+
+ lastAlbum: function(item) {
+ if (item.lastAlbum) {
+ return item.lastAlbum.releaseDate;
+ }
+ return '1/1/1000';
+ },
+
+ albumCount: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.albumCount;
+ },
+
+ trackCount: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.totalTrackCount;
+ },
+
+ sizeOnDisk: function(item) {
+ const { statistics = {} } = item;
+
+ return statistics.sizeOnDisk;
+ },
+
+ ratings: function(item) {
+ const { ratings = {} } = item;
+
+ return ratings.value;
+ }
+ },
+
+ selectedFilterKey: 'all',
+
+ filters,
+
+ filterPredicates: {
+ ...filterPredicates,
+
+ trackProgress: function(item, filterValue, type) {
+ const { statistics = {} } = item;
+
+ const {
+ trackCount = 0,
+ trackFileCount
+ } = statistics;
+
+ const progress = trackCount ?
+ trackFileCount / trackCount * 100 :
+ 100;
+
+ const predicate = filterTypePredicates[type];
+
+ return predicate(progress, filterValue);
+ }
+ },
+
+ filterBuilderProps: [
+ {
+ name: 'monitored',
+ label: 'Monitored',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.BOOL
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.ARTIST_STATUS
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY_PROFILE
+ },
+ {
+ name: 'metadataProfileId',
+ label: 'Metadata Profile',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.METADATA_PROFILE
+ },
+ {
+ name: 'nextAlbum',
+ label: 'Next Album',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'lastAlbum',
+ label: 'Last Album',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'added',
+ label: 'Added',
+ type: filterBuilderTypes.DATE,
+ valueType: filterBuilderValueTypes.DATE
+ },
+ {
+ name: 'albumCount',
+ label: 'Album Count',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'trackProgress',
+ label: 'Track Progress',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ type: filterBuilderTypes.STRING
+ },
+ {
+ name: 'sizeOnDisk',
+ label: 'Size on Disk',
+ type: filterBuilderTypes.NUMBER,
+ valueType: filterBuilderValueTypes.BYTES
+ },
+ {
+ name: 'genres',
+ label: 'Genres',
+ type: filterBuilderTypes.ARRAY,
+ optionsSelector: function(items) {
+ const tagList = items.reduce((acc, artist) => {
+ artist.genres.forEach((genre) => {
+ acc.push({
+ id: genre,
+ name: genre
+ });
+ });
+
+ return acc;
+ }, []);
+
+ return tagList.sort(sortByName);
+ }
+ },
+ {
+ name: 'ratings',
+ label: 'Rating',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.TAG
+ }
+ ]
+};
+
+export const persistState = [
+ 'artistIndex.sortKey',
+ 'artistIndex.sortDirection',
+ 'artistIndex.selectedFilterKey',
+ 'artistIndex.customFilters',
+ 'artistIndex.view',
+ 'artistIndex.columns',
+ 'artistIndex.posterOptions',
+ 'artistIndex.bannerOptions',
+ 'artistIndex.overviewOptions',
+ 'artistIndex.tableOptions'
+];
+
+//
+// Actions Types
+
+export const SET_ARTIST_SORT = 'artistIndex/setArtistSort';
+export const SET_ARTIST_FILTER = 'artistIndex/setArtistFilter';
+export const SET_ARTIST_VIEW = 'artistIndex/setArtistView';
+export const SET_ARTIST_TABLE_OPTION = 'artistIndex/setArtistTableOption';
+export const SET_ARTIST_POSTER_OPTION = 'artistIndex/setArtistPosterOption';
+export const SET_ARTIST_BANNER_OPTION = 'artistIndex/setArtistBannerOption';
+export const SET_ARTIST_OVERVIEW_OPTION = 'artistIndex/setArtistOverviewOption';
+
+//
+// Action Creators
+
+export const setArtistSort = createAction(SET_ARTIST_SORT);
+export const setArtistFilter = createAction(SET_ARTIST_FILTER);
+export const setArtistView = createAction(SET_ARTIST_VIEW);
+export const setArtistTableOption = createAction(SET_ARTIST_TABLE_OPTION);
+export const setArtistPosterOption = createAction(SET_ARTIST_POSTER_OPTION);
+export const setArtistBannerOption = createAction(SET_ARTIST_BANNER_OPTION);
+export const setArtistOverviewOption = createAction(SET_ARTIST_OVERVIEW_OPTION);
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ARTIST_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_ARTIST_FILTER]: createSetClientSideCollectionFilterReducer(section),
+
+ [SET_ARTIST_VIEW]: function(state, { payload }) {
+ return Object.assign({}, state, { view: payload.view });
+ },
+
+ [SET_ARTIST_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [SET_ARTIST_POSTER_OPTION]: function(state, { payload }) {
+ const posterOptions = state.posterOptions;
+
+ return {
+ ...state,
+ posterOptions: {
+ ...posterOptions,
+ ...payload
+ }
+ };
+ },
+
+ [SET_ARTIST_BANNER_OPTION]: function(state, { payload }) {
+ const bannerOptions = state.bannerOptions;
+
+ return {
+ ...state,
+ bannerOptions: {
+ ...bannerOptions,
+ ...payload
+ }
+ };
+ },
+
+ [SET_ARTIST_OVERVIEW_OPTION]: function(state, { payload }) {
+ const overviewOptions = state.overviewOptions;
+
+ return {
+ ...state,
+ overviewOptions: {
+ ...overviewOptions,
+ ...payload
+ }
+ };
+ }
+
+}, 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..2dde21b1e
--- /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: 'artist.sortName',
+ label: 'Artist Name',
+ 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..aee74f14f
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActions.js
@@ -0,0 +1,388 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import moment from 'moment';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { filterTypes } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import * as calendarViews from 'Calendar/calendarViews';
+import * as commandNames from 'Commands/commandNames';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createHandleActions from './Creators/createHandleActions';
+import { set, update } from './baseActions';
+import { executeCommandHelper } from './commandActions';
+
+//
+// Variables
+
+export const section = 'calendar';
+
+const viewRanges = {
+ [calendarViews.DAY]: 'day',
+ [calendarViews.WEEK]: 'week',
+ [calendarViews.MONTH]: 'month',
+ [calendarViews.FORECAST]: 'day'
+};
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ start: null,
+ end: null,
+ dates: [],
+ dayCount: 7,
+ view: window.innerWidth > 768 ? 'week' : 'day',
+ showUpcoming: true,
+ error: null,
+ items: [],
+ searchMissingCommandId: null,
+
+ options: {
+ collapseMultipleAlbums: 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 = createAjaxRequest({
+ url: '/calendar',
+ data: {
+ unmonitored,
+ start,
+ end
+ }
+ }).request;
+
+ 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 { albumIds } = payload;
+
+ const commandPayload = {
+ name: commandNames.ALBUM_SEARCH,
+ albumIds
+ };
+
+ 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..fc3b907f7
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActions.js
@@ -0,0 +1,215 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest({
+ url: '/command',
+ method: 'POST',
+ data: JSON.stringify(payload)
+ }).request;
+
+ 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/createReducers.js b/frontend/src/Store/Actions/createReducers.js
new file mode 100644
index 000000000..11928e4d2
--- /dev/null
+++ b/frontend/src/Store/Actions/createReducers.js
@@ -0,0 +1,23 @@
+import { combineReducers } from 'redux';
+import { enableBatching } from 'redux-batched-actions';
+import actions from 'Store/Actions';
+import { connectRouter } from 'connected-react-router';
+
+const defaultState = {};
+const reducers = {};
+
+actions.forEach((action) => {
+ const section = action.section;
+
+ defaultState[section] = action.defaultState;
+ reducers[section] = action.reducers;
+});
+
+export { defaultState };
+
+export default function(history) {
+ return enableBatching(combineReducers({
+ ...reducers,
+ router: connectRouter(history)
+ }));
+}
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/historyActions.js b/frontend/src/Store/Actions/historyActions.js
new file mode 100644
index 000000000..d7552b938
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -0,0 +1,297 @@
+import { createAction } from 'redux-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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: 'artist.sortName',
+ label: 'Artist',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'album.title',
+ label: 'Album Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackTitle',
+ label: 'Track Title',
+ isVisible: true
+ },
+ {
+ 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: 'trackFileImported',
+ label: 'Track Imported',
+ filters: [
+ {
+ key: 'eventType',
+ value: '3',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'failed',
+ label: 'Download Failed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '4',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'importFailed',
+ label: 'Import Failed',
+ filters: [
+ {
+ key: 'eventType',
+ value: '7',
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'downloadImported',
+ label: 'Download Imported',
+ filters: [
+ {
+ key: 'eventType',
+ value: '8',
+ 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
+ }
+ ]
+ },
+ {
+ key: 'retagged',
+ label: 'Retagged',
+ filters: [
+ {
+ key: 'eventType',
+ value: '9',
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+
+};
+
+export const persistState = [
+ 'history.pageSize',
+ 'history.sortKey',
+ 'history.sortDirection',
+ 'history.selectedFilterKey',
+ 'history.columns'
+];
+
+//
+// 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 = createAjaxRequest({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id
+ }
+ }).request;
+
+ 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/importArtistActions.js b/frontend/src/Store/Actions/importArtistActions.js
new file mode 100644
index 000000000..b4b265a6d
--- /dev/null
+++ b/frontend/src/Store/Actions/importArtistActions.js
@@ -0,0 +1,327 @@
+import _ from 'lodash';
+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 getNewArtist from 'Utilities/Artist/getNewArtist';
+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 = 'importArtist';
+let concurrentLookups = 0;
+let abortCurrentLookup = null;
+const queue = [];
+
+//
+// State
+
+export const defaultState = {
+ isLookingUpArtist: false,
+ isImporting: false,
+ isImported: false,
+ importError: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const QUEUE_LOOKUP_ARTIST = 'importArtist/queueLookupArtist';
+export const START_LOOKUP_ARTIST = 'importArtist/startLookupArtist';
+export const CANCEL_LOOKUP_ARTIST = 'importArtist/cancelLookupArtist';
+export const LOOKUP_UNSEARCHED_ARTIST = 'importArtist/lookupUnsearchedArtist';
+export const CLEAR_IMPORT_ARTIST = 'importArtist/clearImportArtist';
+export const SET_IMPORT_ARTIST_VALUE = 'importArtist/setImportArtistValue';
+export const IMPORT_ARTIST = 'importArtist/importArtist';
+
+//
+// Action Creators
+
+export const queueLookupArtist = createThunk(QUEUE_LOOKUP_ARTIST);
+export const startLookupArtist = createThunk(START_LOOKUP_ARTIST);
+export const importArtist = createThunk(IMPORT_ARTIST);
+export const lookupUnsearchedArtist = createThunk(LOOKUP_UNSEARCHED_ARTIST);
+export const clearImportArtist = createAction(CLEAR_IMPORT_ARTIST);
+export const cancelLookupArtist = createAction(CANCEL_LOOKUP_ARTIST);
+
+export const setImportArtistValue = createAction(SET_IMPORT_ARTIST_VALUE, (payload) => {
+ return {
+ section,
+ ...payload
+ };
+});
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [QUEUE_LOOKUP_ARTIST]: function(getState, payload, dispatch) {
+ const {
+ name,
+ path,
+ term,
+ topOfQueue = false
+ } = payload;
+
+ const state = getState().importArtist;
+ const item = _.find(state.items, { id: name }) || {
+ id: name,
+ term,
+ path,
+ isFetching: false,
+ isPopulated: false,
+ error: null
+ };
+
+ dispatch(updateItem({
+ section,
+ ...item,
+ term,
+ isQueued: true,
+ items: []
+ }));
+
+ const itemIndex = queue.indexOf(item.id);
+
+ if (itemIndex >= 0) {
+ queue.splice(itemIndex, 1);
+ }
+
+ if (topOfQueue) {
+ queue.unshift(item.id);
+ } else {
+ queue.push(item.id);
+ }
+
+ if (term && term.length > 2) {
+ dispatch(startLookupArtist({ start: true }));
+ }
+ },
+
+ [START_LOOKUP_ARTIST]: function(getState, payload, dispatch) {
+ if (concurrentLookups >= 1) {
+ return;
+ }
+
+ const state = getState().importArtist;
+
+ const {
+ isLookingUpArtist,
+ items
+ } = state;
+
+ const queueId = queue[0];
+
+ if (payload.start && !isLookingUpArtist) {
+ dispatch(set({ section, isLookingUpArtist: true }));
+ } else if (!isLookingUpArtist) {
+ return;
+ } else if (!queueId) {
+ dispatch(set({ section, isLookingUpArtist: 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: '/artist/lookup',
+ data: {
+ term: queued.term
+ }
+ });
+
+ abortCurrentLookup = abortRequest;
+
+ request.done((data) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data,
+ isQueued: false,
+ selectedArtist: queued.selectedArtist || data[0],
+ updateOnly: true
+ }));
+ });
+
+ request.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr,
+ isQueued: false,
+ updateOnly: true
+ }));
+ });
+
+ request.always(() => {
+ concurrentLookups--;
+
+ dispatch(startLookupArtist());
+ });
+ },
+
+ [LOOKUP_UNSEARCHED_ARTIST]: function(getState, payload, dispatch) {
+ const state = getState().importArtist;
+
+ if (state.isLookingUpArtist) {
+ return;
+ }
+
+ state.items.forEach((item) => {
+ const id = item.id;
+
+ if (
+ !item.isPopulated &&
+ !queue.includes(id)
+ ) {
+ queue.push(item.id);
+ }
+ });
+
+ if (queue.length) {
+ dispatch(startLookupArtist({ start: true }));
+ }
+ },
+
+ [IMPORT_ARTIST]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isImporting: true }));
+
+ const ids = payload.ids;
+ const items = getState().importArtist.items;
+ const addedIds = [];
+
+ const allNewArtist = ids.reduce((acc, id) => {
+ const item = _.find(items, { id });
+ const selectedArtist = item.selectedArtist;
+
+ // Make sure we have a selected artist and
+ // the same artist hasn't been added yet.
+ if (selectedArtist && !_.some(acc, { foreignArtistId: selectedArtist.foreignArtistId })) {
+ const newArtist = getNewArtist(_.cloneDeep(selectedArtist), item);
+ newArtist.path = item.path;
+
+ addedIds.push(id);
+ acc.push(newArtist);
+ }
+
+ return acc;
+ }, []);
+
+ const promise = createAjaxRequest({
+ url: '/artist/import',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(allNewArtist)
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isImporting: false,
+ isImported: true
+ }),
+
+ ...data.map((artist) => updateItem({ section: 'artist', ...artist })),
+
+ ...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_ARTIST]: function(state) {
+ queue.splice(0, queue.length);
+
+ const items = state.items.map((item) => {
+ if (item.isQueued) {
+ return {
+ ...item,
+ isQueued: false
+ };
+ }
+
+ return item;
+ });
+
+ return Object.assign({}, state, {
+ isLookingUpArtist: false,
+ items
+ });
+ },
+
+ [CLEAR_IMPORT_ARTIST]: function(state) {
+ if (abortCurrentLookup) {
+ abortCurrentLookup();
+
+ abortCurrentLookup = null;
+ }
+
+ queue.splice(0, queue.length);
+
+ return Object.assign({}, state, defaultState);
+ },
+
+ [SET_IMPORT_ARTIST_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..8e04a20cf
--- /dev/null
+++ b/frontend/src/Store/Actions/index.js
@@ -0,0 +1,65 @@
+import * as addArtist from './addArtistActions';
+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 commands from './commandActions';
+import * as albums from './albumActions';
+import * as trackFiles from './trackFileActions';
+import * as albumHistory from './albumHistoryActions';
+import * as history from './historyActions';
+import * as importArtist from './importArtistActions';
+import * as interactiveImportActions from './interactiveImportActions';
+import * as oAuth from './oAuthActions';
+import * as organizePreview from './organizePreviewActions';
+import * as retagPreview from './retagPreviewActions';
+import * as paths from './pathActions';
+import * as providerOptions from './providerOptionActions';
+import * as queue from './queueActions';
+import * as releases from './releaseActions';
+import * as rootFolders from './rootFolderActions';
+import * as albumStudio from './albumStudioActions';
+import * as artist from './artistActions';
+import * as artistEditor from './artistEditorActions';
+import * as artistHistory from './artistHistoryActions';
+import * as artistIndex from './artistIndexActions';
+import * as settings from './settingsActions';
+import * as system from './systemActions';
+import * as tags from './tagActions';
+import * as tracks from './trackActions';
+import * as wanted from './wantedActions';
+
+export default [
+ addArtist,
+ app,
+ blacklist,
+ captcha,
+ calendar,
+ commands,
+ customFilters,
+ albums,
+ trackFiles,
+ albumHistory,
+ history,
+ importArtist,
+ interactiveImportActions,
+ oAuth,
+ organizePreview,
+ retagPreview,
+ paths,
+ providerOptions,
+ queue,
+ releases,
+ rootFolders,
+ albumStudio,
+ artist,
+ artistEditor,
+ artistHistory,
+ artistIndex,
+ settings,
+ system,
+ tags,
+ tracks,
+ wanted
+];
diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js
new file mode 100644
index 000000000..7b0607885
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActions.js
@@ -0,0 +1,254 @@
+import moment from 'moment';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import { set, update } from './baseActions';
+
+//
+// Variables
+
+export const section = 'interactiveImport';
+
+const albumsSection = `${section}.albums`;
+const trackFilesSection = `${section}.trackFiles`;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ isSaving: false,
+ error: null,
+ items: [],
+ pendingChanges: {},
+ sortKey: 'quality',
+ sortDirection: sortDirections.DESCENDING,
+ recentFolders: [],
+ importMode: 'move',
+ sortPredicates: {
+ relativePath: function(item, direction) {
+ const relativePath = item.relativePath;
+
+ return relativePath.toLowerCase();
+ },
+
+ artist: function(item, direction) {
+ const artist = item.artist;
+
+ return artist ? artist.sortName : '';
+ },
+
+ quality: function(item, direction) {
+ return item.quality ? item.qualityWeight : 0;
+ }
+ },
+
+ albums: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'albumTitle',
+ sortDirection: sortDirections.ASCENDING,
+ items: []
+ },
+
+ trackFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'relataivePath',
+ sortDirection: sortDirections.ASCENDING,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'interactiveImport.recentFolders',
+ 'interactiveImport.importMode'
+];
+
+//
+// Actions Types
+
+export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems';
+export const SAVE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/saveInteractiveImportItem';
+export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort';
+export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem';
+export const UPDATE_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/updateInteractiveImportItems';
+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_ALBUMS = 'interactiveImport/fetchInteractiveImportAlbums';
+export const SET_INTERACTIVE_IMPORT_ALBUMS_SORT = 'interactiveImport/clearInteractiveImportAlbumsSort';
+export const CLEAR_INTERACTIVE_IMPORT_ALBUMS = 'interactiveImport/clearInteractiveImportAlbums';
+
+export const FETCH_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/fetchInteractiveImportTrackFiles';
+export const CLEAR_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/clearInteractiveImportTrackFiles';
+
+//
+// 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 updateInteractiveImportItems = createAction(UPDATE_INTERACTIVE_IMPORT_ITEMS);
+export const saveInteractiveImportItem = createThunk(SAVE_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 fetchInteractiveImportAlbums = createThunk(FETCH_INTERACTIVE_IMPORT_ALBUMS);
+export const setInteractiveImportAlbumsSort = createAction(SET_INTERACTIVE_IMPORT_ALBUMS_SORT);
+export const clearInteractiveImportAlbums = createAction(CLEAR_INTERACTIVE_IMPORT_ALBUMS);
+
+export const fetchInteractiveImportTrackFiles = createThunk(FETCH_INTERACTIVE_IMPORT_TRACKFILES);
+export const clearInteractiveImportTrackFiles = createAction(CLEAR_INTERACTIVE_IMPORT_TRACKFILES);
+
+//
+// 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 = createAjaxRequest({
+ url: '/manualimport',
+ data: payload
+ }).request;
+
+ 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
+ }));
+ });
+ },
+
+ [SAVE_INTERACTIVE_IMPORT_ITEM]: createSaveProviderHandler(section, '/manualimport', {}, true),
+
+ [FETCH_INTERACTIVE_IMPORT_ALBUMS]: createFetchHandler(albumsSection, '/album'),
+
+ [FETCH_INTERACTIVE_IMPORT_TRACKFILES]: createFetchHandler(trackFilesSection, '/trackFile')
+});
+
+//
+// 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;
+ },
+
+ [UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => {
+ const ids = payload.ids;
+ const newState = Object.assign({}, state);
+ const items = [...newState.items];
+
+ ids.forEach((id) => {
+ const index = items.findIndex((item) => item.id === id);
+ const item = Object.assign({}, items[index], payload);
+
+ items.splice(index, 1, item);
+ });
+
+ newState.items = items;
+
+ 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_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(albumsSection),
+
+ [CLEAR_INTERACTIVE_IMPORT_ALBUMS]: (state) => {
+ return updateSectionState(state, albumsSection, {
+ ...defaultState.albums
+ });
+ },
+
+ [CLEAR_INTERACTIVE_IMPORT_TRACKFILES]: (state) => {
+ return updateSectionState(state, trackFilesSection, {
+ ...defaultState.trackFiles
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js
new file mode 100644
index 000000000..07ada4a90
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActions.js
@@ -0,0 +1,206 @@
+import $ from 'jquery';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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.Lidarr.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 createAjaxRequest(ajaxOptions).request.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..139ab9e23
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActions.js
@@ -0,0 +1,112 @@
+import { createAction } from 'redux-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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,
+ includeFiles = false
+ } = payload;
+
+ const promise = createAjaxRequest({
+ url: '/filesystem',
+ data: {
+ path,
+ allowFoldersWithoutTrailingSlashes,
+ includeFiles
+ }
+ }).request;
+
+ 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/providerOptionActions.js b/frontend/src/Store/Actions/providerOptionActions.js
new file mode 100644
index 000000000..c8d05e7e1
--- /dev/null
+++ b/frontend/src/Store/Actions/providerOptionActions.js
@@ -0,0 +1,78 @@
+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 = 'providerOptions';
+
+//
+// State
+
+export const defaultState = {
+ items: [],
+ isFetching: false,
+ isPopulated: false,
+ error: false
+};
+
+//
+// Actions Types
+
+export const FETCH_OPTIONS = 'devices/fetchOptions';
+export const CLEAR_OPTIONS = 'devices/clearOptions';
+
+//
+// Action Creators
+
+export const fetchOptions = createThunk(FETCH_OPTIONS);
+export const clearOptions = createAction(CLEAR_OPTIONS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_OPTIONS]: function(getState, payload, dispatch) {
+ dispatch(set({
+ section,
+ isFetching: true
+ }));
+
+ const promise = requestAction(payload);
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data.options || []
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_OPTIONS]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js
new file mode 100644
index 000000000..85c301c7d
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActions.js
@@ -0,0 +1,448 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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: {
+ includeUnknownArtistItems: 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: 'artist.sortName',
+ label: 'Artist',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'album.title',
+ label: 'Album Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'album.releaseDate',
+ label: 'Album Release 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: 'title',
+ label: 'Release Title',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'outputPath',
+ label: 'Output Path',
+ isSortable: false,
+ 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.includeUnknownArtistItems = getState().queue.options.includeUnknownArtistItems;
+}
+
+//
+// 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 = createAjaxRequest({
+ url: `/queue/grab/${id}`,
+ method: 'POST'
+ }).request;
+
+ 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 = createAjaxRequest({
+ url: '/queue/grab/bulk',
+ method: 'POST',
+ dataType: 'json',
+ data: JSON.stringify(payload)
+ }).request;
+
+ 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,
+ skipredownload
+ } = payload;
+
+ dispatch(updateItem({ section: paged, id, isRemoving: true }));
+
+ const promise = createAjaxRequest({
+ url: `/queue/${id}?blacklist=${blacklist}&skipredownload=${skipredownload}`,
+ method: 'DELETE'
+ }).request;
+
+ 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,
+ skipredownload
+ } = payload;
+
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section: paged,
+ id,
+ isRemoving: true
+ });
+ }),
+
+ set({ section: paged, isRemoving: true })
+ ]));
+
+ const promise = createAjaxRequest({
+ url: `/queue/bulk?blacklist=${blacklist}&skipredownload=${skipredownload}`,
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ ids })
+ }).request;
+
+ 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/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
new file mode 100644
index 000000000..fefb64399
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -0,0 +1,282 @@
+import { createAction } from 'redux-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 albumSection = 'releases.album';
+export const artistSection = 'releases.artist';
+
+let abortCurrentRequest = null;
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'releaseWeight',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ age: function(item, direction) {
+ return item.ageMinutes;
+ },
+ 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: 'discography-pack',
+ label: 'Discography',
+ filters: [
+ {
+ key: 'discography',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'not-discography-pack',
+ label: 'Not Discography',
+ filters: [
+ {
+ key: 'discography',
+ 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: 'leechers',
+ label: 'Peers',
+ type: filterBuilderTypes.NUMBER
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ type: filterBuilderTypes.EXACT,
+ valueType: filterBuilderValueTypes.QUALITY
+ },
+ {
+ name: 'rejections',
+ label: 'Rejections',
+ type: filterBuilderTypes.NUMBER
+ }
+ ],
+
+ album: {
+ selectedFilterKey: 'all'
+ },
+
+ artist: {
+ selectedFilterKey: 'discography-pack'
+ }
+};
+
+export const persistState = [
+ 'releases.selectedFilterKey',
+ 'releases.album.customFilters',
+ 'releases.artist.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_ALBUM_RELEASES_FILTER = 'releases/setAlbumReleasesFilter';
+export const SET_ARTIST_RELEASES_FILTER = 'releases/setArtistReleasesFilter';
+
+//
+// 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 setAlbumReleasesFilter = createAction(SET_ALBUM_RELEASES_FILTER);
+export const setArtistReleasesFilter = createAction(SET_ARTIST_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 = createAjaxRequest({
+ url: '/release',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(payload)
+ }).request;
+
+ 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 {
+ album,
+ artist,
+ ...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_ALBUM_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(albumSection),
+ [SET_ARTIST_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(artistSection)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/retagPreviewActions.js b/frontend/src/Store/Actions/retagPreviewActions.js
new file mode 100644
index 000000000..73632fcf8
--- /dev/null
+++ b/frontend/src/Store/Actions/retagPreviewActions.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 = 'retagPreview';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_RETAG_PREVIEW = 'retagPreview/fetchRetagPreview';
+export const CLEAR_RETAG_PREVIEW = 'retagPreview/clearRetagPreview';
+
+//
+// Action Creators
+
+export const fetchRetagPreview = createThunk(FETCH_RETAG_PREVIEW);
+export const clearRetagPreview = createAction(CLEAR_RETAG_PREVIEW);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_RETAG_PREVIEW]: createFetchHandler('retagPreview', '/retag')
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_RETAG_PREVIEW]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js
new file mode 100644
index 000000000..3e3c7de8a
--- /dev/null
+++ b/frontend/src/Store/Actions/rootFolderActions.js
@@ -0,0 +1,97 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest({
+ url: '/rootFolder',
+ method: 'POST',
+ data: JSON.stringify({ path }),
+ dataType: 'json'
+ }).request;
+
+ 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..2a7cfc8b9
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActions.js
@@ -0,0 +1,149 @@
+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 importLists from './Settings/importLists';
+import importListExclusions from './Settings/importListExclusions';
+import metadataProfiles from './Settings/metadataProfiles';
+import mediaManagement from './Settings/mediaManagement';
+import metadata from './Settings/metadata';
+import metadataProvider from './Settings/metadataProvider';
+import naming from './Settings/naming';
+import namingExamples from './Settings/namingExamples';
+import notifications from './Settings/notifications';
+import qualityDefinitions from './Settings/qualityDefinitions';
+import qualityProfiles from './Settings/qualityProfiles';
+import releaseProfiles from './Settings/releaseProfiles';
+import remotePathMappings from './Settings/remotePathMappings';
+import ui from './Settings/ui';
+
+export * from './Settings/delayProfiles';
+export * from './Settings/downloadClients';
+export * from './Settings/downloadClientOptions';
+export * from './Settings/general';
+export * from './Settings/importLists';
+export * from './Settings/importListExclusions';
+export * from './Settings/indexerOptions';
+export * from './Settings/indexers';
+export * from './Settings/metadataProfiles';
+export * from './Settings/mediaManagement';
+export * from './Settings/metadata';
+export * from './Settings/metadataProvider';
+export * from './Settings/naming';
+export * from './Settings/namingExamples';
+export * from './Settings/notifications';
+export * from './Settings/qualityDefinitions';
+export * from './Settings/qualityProfiles';
+export * from './Settings/releaseProfiles';
+export * from './Settings/remotePathMappings';
+export * from './Settings/ui';
+
+//
+// Variables
+
+export const section = 'settings';
+
+//
+// State
+
+export const defaultState = {
+ advancedSettings: false,
+
+ delayProfiles: delayProfiles.defaultState,
+ downloadClients: downloadClients.defaultState,
+ downloadClientOptions: downloadClientOptions.defaultState,
+ general: general.defaultState,
+ indexerOptions: indexerOptions.defaultState,
+ indexers: indexers.defaultState,
+ importLists: importLists.defaultState,
+ importListExclusions: importListExclusions.defaultState,
+ metadataProfiles: metadataProfiles.defaultState,
+ mediaManagement: mediaManagement.defaultState,
+ metadata: metadata.defaultState,
+ metadataProvider: metadataProvider.defaultState,
+ naming: naming.defaultState,
+ namingExamples: namingExamples.defaultState,
+ notifications: notifications.defaultState,
+ qualityDefinitions: qualityDefinitions.defaultState,
+ qualityProfiles: qualityProfiles.defaultState,
+ releaseProfiles: releaseProfiles.defaultState,
+ remotePathMappings: remotePathMappings.defaultState,
+ ui: ui.defaultState
+};
+
+export const persistState = [
+ 'settings.advancedSettings'
+];
+
+//
+// Actions Types
+
+export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings';
+
+//
+// Action Creators
+
+export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ ...delayProfiles.actionHandlers,
+ ...downloadClients.actionHandlers,
+ ...downloadClientOptions.actionHandlers,
+ ...general.actionHandlers,
+ ...indexerOptions.actionHandlers,
+ ...indexers.actionHandlers,
+ ...importLists.actionHandlers,
+ ...importListExclusions.actionHandlers,
+ ...metadataProfiles.actionHandlers,
+ ...mediaManagement.actionHandlers,
+ ...metadata.actionHandlers,
+ ...metadataProvider.actionHandlers,
+ ...naming.actionHandlers,
+ ...namingExamples.actionHandlers,
+ ...notifications.actionHandlers,
+ ...qualityDefinitions.actionHandlers,
+ ...qualityProfiles.actionHandlers,
+ ...releaseProfiles.actionHandlers,
+ ...remotePathMappings.actionHandlers,
+ ...ui.actionHandlers
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => {
+ return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
+ },
+
+ ...delayProfiles.reducers,
+ ...downloadClients.reducers,
+ ...downloadClientOptions.reducers,
+ ...general.reducers,
+ ...indexerOptions.reducers,
+ ...indexers.reducers,
+ ...importLists.reducers,
+ ...importListExclusions.reducers,
+ ...metadataProfiles.reducers,
+ ...mediaManagement.reducers,
+ ...metadata.reducers,
+ ...metadataProvider.reducers,
+ ...naming.reducers,
+ ...namingExamples.reducers,
+ ...notifications.reducers,
+ ...qualityDefinitions.reducers,
+ ...qualityProfiles.reducers,
+ ...releaseProfiles.reducers,
+ ...remotePathMappings.reducers,
+ ...ui.reducers
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js
new file mode 100644
index 000000000..d238a92f1
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActions.js
@@ -0,0 +1,392 @@
+import { createAction } from 'redux-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest(ajaxOptions).request;
+
+ 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 = createAjaxRequest({
+ url: '/system/restart',
+ method: 'POST'
+ }).request;
+
+ promise.done(() => {
+ dispatch(setAppValue({ isRestarting: true }));
+ });
+ },
+
+ [SHUTDOWN]: function() {
+ createAjaxRequest({
+ 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..5389b1a6b
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActions.js
@@ -0,0 +1,75 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+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 = createAjaxRequest({
+ url: '/tag',
+ method: 'POST',
+ data: JSON.stringify(payload.tag)
+ }).request;
+
+ promise.done((data) => {
+ const tags = getState().tags.items.slice();
+ tags.push(data);
+
+ dispatch(update({ section, data: tags }));
+ payload.onTagCreated(data);
+ });
+ },
+
+ [DELETE_TAG]: createRemoveItemHandler(section, '/tag'),
+ [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail')
+
+});
+
+//
+// Reducers
+export const reducers = createHandleActions({}, defaultState, section);
diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js
new file mode 100644
index 000000000..44292271a
--- /dev/null
+++ b/frontend/src/Store/Actions/trackActions.js
@@ -0,0 +1,125 @@
+import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'tracks';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'mediumNumber',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'absoluteTrackNumber',
+ secondarySortDirection: sortDirections.ASCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'medium',
+ label: 'Medium',
+ isVisible: false
+ },
+ {
+ name: 'absoluteTrackNumber',
+ label: 'Track',
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isVisible: false
+ },
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isVisible: false
+ },
+ {
+ name: 'duration',
+ label: 'Duration',
+ isVisible: true
+ },
+ {
+ name: 'audioInfo',
+ label: 'Audio Info',
+ isVisible: true
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'tracks.sortKey',
+ 'tracks.sortDirection',
+ 'tracks.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_TRACKS = 'tracks/fetchTracks';
+export const SET_TRACKS_SORT = 'tracks/setTracksSort';
+export const SET_TRACKS_TABLE_OPTION = 'tracks/setTracksTableOption';
+export const CLEAR_TRACKS = 'tracks/clearTracks';
+
+//
+// Action Creators
+
+export const fetchTracks = createThunk(FETCH_TRACKS);
+export const setTracksSort = createAction(SET_TRACKS_SORT);
+export const setTracksTableOption = createAction(SET_TRACKS_TABLE_OPTION);
+export const clearTracks = createAction(CLEAR_TRACKS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_TRACKS]: createFetchHandler(section, '/track')
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_TRACKS_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [FETCH_TRACKS]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ },
+
+ [SET_TRACKS_SORT]: createSetClientSideCollectionSortReducer(section)
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/trackFileActions.js b/frontend/src/Store/Actions/trackFileActions.js
new file mode 100644
index 000000000..331c6c05c
--- /dev/null
+++ b/frontend/src/Store/Actions/trackFileActions.js
@@ -0,0 +1,262 @@
+import _ from 'lodash';
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import albumEntities from 'Album/albumEntities';
+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 = 'trackFiles';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ sortKey: 'path',
+ sortDirection: sortDirections.ASCENDING,
+
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+
+ sortPredicates: {
+ quality: function(item, direction) {
+ return item.quality ? item.qualityWeight : 0;
+ }
+ },
+
+ columns: [
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'dateAdded',
+ label: 'Date Added',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'trackFiles.sortKey',
+ 'trackFiles.sortDirection'
+];
+
+//
+// Actions Types
+
+export const FETCH_TRACK_FILES = 'trackFiles/fetchTrackFiles';
+export const DELETE_TRACK_FILE = 'trackFiles/deleteTrackFile';
+export const DELETE_TRACK_FILES = 'trackFiles/deleteTrackFiles';
+export const UPDATE_TRACK_FILES = 'trackFiles/updateTrackFiles';
+export const SET_TRACK_FILES_SORT = 'trackFiles/setTrackFilesSort';
+export const SET_TRACK_FILES_TABLE_OPTION = 'trackFiles/setTrackFilesTableOption';
+export const CLEAR_TRACK_FILES = 'trackFiles/clearTrackFiles';
+
+//
+// Action Creators
+
+export const fetchTrackFiles = createThunk(FETCH_TRACK_FILES);
+export const deleteTrackFile = createThunk(DELETE_TRACK_FILE);
+export const deleteTrackFiles = createThunk(DELETE_TRACK_FILES);
+export const updateTrackFiles = createThunk(UPDATE_TRACK_FILES);
+export const setTrackFilesSort = createAction(SET_TRACK_FILES_SORT);
+export const setTrackFilesTableOption = createAction(SET_TRACK_FILES_TABLE_OPTION);
+export const clearTrackFiles = createAction(CLEAR_TRACK_FILES);
+
+//
+// Helpers
+
+const deleteTrackFileHelper = createRemoveItemHandler(section, '/trackFile');
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_TRACK_FILES]: createFetchHandler(section, '/trackFile'),
+
+ [DELETE_TRACK_FILE]: function(getState, payload, dispatch) {
+ const {
+ id: trackFileId,
+ albumEntity = albumEntities.ALBUMS
+ } = payload;
+
+ const albumSection = _.last(albumEntity.split('.'));
+ const deletePromise = deleteTrackFileHelper(getState, payload, dispatch);
+
+ deletePromise.done(() => {
+ const albums = getState().albums.items;
+ const tracksWithRemovedFiles = _.filter(albums, { trackFileId });
+
+ dispatch(batchActions([
+ ...tracksWithRemovedFiles.map((track) => {
+ return updateItem({
+ section: albumSection,
+ ...track,
+ trackFileId: 0,
+ hasFile: false
+ });
+ })
+ ]));
+ });
+ },
+
+ [DELETE_TRACK_FILES]: function(getState, payload, dispatch) {
+ const {
+ trackFileIds
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const promise = createAjaxRequest({
+ url: '/trackFile/bulk',
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ trackFileIds })
+ }).request;
+
+ promise.done(() => {
+ const tracks = getState().tracks.items;
+ const tracksWithRemovedFiles = trackFileIds.reduce((acc, trackFileId) => {
+ acc.push(..._.filter(tracks, { trackFileId }));
+
+ return acc;
+ }, []);
+
+ dispatch(batchActions([
+ ...trackFileIds.map((id) => {
+ return removeItem({ section, id });
+ }),
+
+ ...tracksWithRemovedFiles.map((track) => {
+ return updateItem({
+ section: 'tracks',
+ ...track,
+ trackFileId: 0,
+ hasFile: false
+ });
+ }),
+
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ },
+
+ [UPDATE_TRACK_FILES]: function(getState, payload, dispatch) {
+ const {
+ trackFileIds,
+ quality
+ } = payload;
+
+ dispatch(set({ section, isSaving: true }));
+
+ const data = {
+ trackFileIds
+ };
+
+ if (quality) {
+ data.quality = quality;
+ }
+
+ const promise = createAjaxRequest({
+ url: '/trackFile/editor',
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(data)
+ }).request;
+
+ promise.done(() => {
+ dispatch(batchActions([
+ ...trackFileIds.map((id) => {
+ const props = {};
+
+ 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({
+ [SET_TRACK_FILES_SORT]: createSetClientSideCollectionSortReducer(section),
+ [SET_TRACK_FILES_TABLE_OPTION]: createSetTableOptionReducer(section),
+
+ [CLEAR_TRACK_FILES]: createClearReducer(section, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js
new file mode 100644
index 000000000..4df132504
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActions.js
@@ -0,0 +1,316 @@
+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 createBatchToggleAlbumMonitoredHandler from './Creators/createBatchToggleAlbumMonitoredHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'wanted';
+
+//
+// State
+
+export const defaultState = {
+ missing: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'artist.sortName',
+ label: 'Artist Name',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albumTitle',
+ label: 'Album Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albumType',
+ label: 'Album Type',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: 'Release Date',
+ isSortable: true,
+ isVisible: true
+ },
+ // {
+ // name: 'status',
+ // label: 'Status',
+ // isVisible: true
+ // },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: 'Monitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: 'Unmonitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ },
+
+ cutoffUnmet: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'artist.sortName',
+ label: 'Artist Name',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albumTitle',
+ label: 'Album Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'albumType',
+ label: 'Album Type',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: 'Release Date',
+ isSortable: true,
+ isVisible: true
+ },
+ // {
+ // name: 'status',
+ // label: 'Status',
+ // isVisible: true
+ // },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: 'Monitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: 'Unmonitored',
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'wanted.missing.pageSize',
+ 'wanted.missing.sortKey',
+ 'wanted.missing.sortDirection',
+ 'wanted.missing.selectedFilterKey',
+ 'wanted.missing.columns',
+ 'wanted.cutoffUnmet.pageSize',
+ 'wanted.cutoffUnmet.sortKey',
+ 'wanted.cutoffUnmet.sortDirection',
+ 'wanted.cutoffUnmet.selectedFilterKey',
+ 'wanted.cutoffUnmet.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_MISSING = 'wanted/missing/fetchMissing';
+export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
+export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
+export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
+export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
+export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
+export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
+export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
+export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
+export const CLEAR_MISSING = 'wanted/missing/clearMissing';
+
+export const BATCH_TOGGLE_MISSING_ALBUMS = 'wanted/missing/batchToggleMissingAlbums';
+
+export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
+export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
+export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
+export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
+export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
+export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
+export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
+export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
+export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
+export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
+
+export const BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS = 'wanted/cutoffUnmet/batchToggleCutoffUnmetAlbums';
+
+//
+// Action Creators
+
+export const fetchMissing = createThunk(FETCH_MISSING);
+export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
+export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
+export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
+export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
+export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
+export const setMissingSort = createThunk(SET_MISSING_SORT);
+export const setMissingFilter = createThunk(SET_MISSING_FILTER);
+export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
+export const clearMissing = createAction(CLEAR_MISSING);
+
+export const batchToggleMissingAlbums = createThunk(BATCH_TOGGLE_MISSING_ALBUMS);
+
+export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
+export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
+export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
+export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
+export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
+export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
+
+export const batchToggleCutoffUnmetAlbums = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.missing',
+ '/wanted/missing',
+ fetchMissing,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_MISSING_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.missing', fetchMissing),
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.cutoffUnmet',
+ '/wanted/cutoff',
+ fetchCutoffUnmet,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
+ [SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
+
+ [CLEAR_MISSING]: createClearReducer(
+ 'wanted.missing',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ ),
+
+ [CLEAR_CUTOFF_UNMET]: createClearReducer(
+ 'wanted.cutoffUnmet',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ )
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js
new file mode 100644
index 000000000..407044d56
--- /dev/null
+++ b/frontend/src/Store/Middleware/createPersistState.js
@@ -0,0 +1,101 @@
+import _ from 'lodash';
+import persistState from 'redux-localstorage';
+import actions from 'Store/Actions';
+import migrate from 'Store/Migrators/migrate';
+
+const columnPaths = [];
+
+const paths = _.reduce([...actions], (acc, action) => {
+ if (action.persistState) {
+ action.persistState.forEach((path) => {
+ if (path.match(/\.columns$/)) {
+ columnPaths.push(path);
+ }
+
+ acc.push(path);
+ });
+ }
+
+ return acc;
+}, []);
+
+function mergeColumns(path, initialState, persistedState, computedState) {
+ const initialColumns = _.get(initialState, path);
+ const persistedColumns = _.get(persistedState, path);
+
+ if (!persistedColumns || !persistedColumns.length) {
+ return;
+ }
+
+ const columns = [];
+
+ initialColumns.forEach((initialColumn) => {
+ const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name });
+ const column = Object.assign({}, initialColumn);
+ const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined;
+
+ if (persistedColumn) {
+ column.isVisible = persistedColumn.isVisible;
+ }
+
+ // If there is a persisted column, it's index doesn't exceed the column list
+ // and it's modifiable, insert it in the proper position.
+
+ if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) {
+ columns.splice(persistedColumnIndex, 0, column);
+ } else {
+ columns.push(column);
+ }
+
+ // Set the columns in the persisted state
+ _.set(computedState, path, columns);
+ });
+}
+
+function slicer(paths_) {
+ return (state) => {
+ const subset = {};
+
+ paths_.forEach((path) => {
+ _.set(subset, path, _.get(state, path));
+ });
+
+ return subset;
+ };
+}
+
+function serialize(obj) {
+ return JSON.stringify(obj, null, 2);
+}
+
+function merge(initialState, persistedState) {
+ if (!persistedState) {
+ return initialState;
+ }
+
+ const computedState = {};
+
+ _.merge(computedState, initialState, persistedState);
+
+ columnPaths.forEach((columnPath) => {
+ mergeColumns(columnPath, initialState, persistedState, computedState);
+ });
+
+ return computedState;
+}
+
+const config = {
+ slicer,
+ serialize,
+ merge,
+ key: 'lidarr'
+};
+
+export default function createPersistState() {
+ // Migrate existing local storage before proceeding
+ const persistedState = JSON.parse(localStorage.getItem(config.key));
+ migrate(persistedState);
+ localStorage.setItem(config.key, serialize(persistedState));
+
+ return persistState(paths, config);
+}
diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js
new file mode 100644
index 000000000..b567c83f1
--- /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,
+ userHash,
+ isProduction
+ } = window.Lidarr;
+
+ if (!analytics) {
+ return;
+ }
+
+ const dsn = isProduction ? 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290' :
+ 'https://baede6f14da54cf48ff431479e400adf@sentry.io/1249427';
+
+ sentry.init({
+ dsn,
+ environment: branch,
+ release,
+ sendDefaultPii: true,
+ beforeSend: cleanseData
+ });
+
+ sentry.configureScope((scope) => {
+ scope.setUser({ username: userHash });
+ scope.setTag('version', version);
+ scope.setTag('production', isProduction);
+ });
+
+ return createMiddleware();
+}
diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js
new file mode 100644
index 000000000..119743b23
--- /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 'connected-react-router';
+import createSentryMiddleware from './createSentryMiddleware';
+import createPersistState from './createPersistState';
+
+export default function(history) {
+ const middlewares = [];
+ const sentryMiddleware = createSentryMiddleware();
+
+ if (sentryMiddleware) {
+ middlewares.push(sentryMiddleware);
+ }
+
+ middlewares.push(routerMiddleware(history));
+ middlewares.push(thunk);
+
+ // eslint-disable-next-line no-underscore-dangle
+ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+
+ return composeEnhancers(
+ applyMiddleware(...middlewares),
+ createPersistState()
+ );
+}
diff --git a/frontend/src/Store/Migrators/migrate.js b/frontend/src/Store/Migrators/migrate.js
new file mode 100644
index 000000000..36dbbb8c3
--- /dev/null
+++ b/frontend/src/Store/Migrators/migrate.js
@@ -0,0 +1,5 @@
+import migrateAddArtistDefaults from './migrateAddArtistDefaults';
+
+export default function migrate(persistedState) {
+ migrateAddArtistDefaults(persistedState);
+}
diff --git a/frontend/src/Store/Migrators/migrateAddArtistDefaults.js b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js
new file mode 100644
index 000000000..731bb00c6
--- /dev/null
+++ b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js
@@ -0,0 +1,14 @@
+import { get } from 'lodash';
+import monitorOptions from 'Utilities/Artist/monitorOptions';
+
+export default function migrateAddArtistDefaults(persistedState) {
+ const monitor = get(persistedState, 'addArtist.defaults.monitor');
+
+ if (!monitor) {
+ return;
+ }
+
+ if (!monitorOptions.find((option) => option.key === monitor)) {
+ persistedState.addArtist.defaults.monitor = monitorOptions[0].key;
+ }
+}
diff --git a/frontend/src/Store/Selectors/createAlbumSelector.js b/frontend/src/Store/Selectors/createAlbumSelector.js
new file mode 100644
index 000000000..13894a143
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAlbumSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import albumEntities from 'Album/albumEntities';
+
+function createAlbumSelector() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state, { albumEntity = albumEntities.ALBUMS }) => _.get(state, albumEntity, { items: [] }),
+ (albumId, albums) => {
+ return _.find(albums.items, { id: albumId });
+ }
+ );
+}
+
+export default createAlbumSelector;
diff --git a/frontend/src/Store/Selectors/createAllArtistSelector.js b/frontend/src/Store/Selectors/createAllArtistSelector.js
new file mode 100644
index 000000000..38b1bcef1
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAllArtistSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createAllArtistSelector() {
+ return createSelector(
+ (state) => state.artist,
+ (artist) => {
+ return artist.items;
+ }
+ );
+}
+
+export default createAllArtistSelector;
diff --git a/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js
new file mode 100644
index 000000000..38300bd9d
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js
@@ -0,0 +1,36 @@
+import { createSelector } from 'reselect';
+import createDeepEqualSelector from './createDeepEqualSelector';
+import createClientSideCollectionSelector from './createClientSideCollectionSelector';
+
+function createUnoptimizedSelector(uiSection) {
+ return createSelector(
+ createClientSideCollectionSelector('artist', uiSection),
+ (artist) => {
+ const items = artist.items.map((s) => {
+ const {
+ id,
+ sortName
+ } = s;
+
+ return {
+ id,
+ sortName
+ };
+ });
+
+ return {
+ ...artist,
+ items
+ };
+ }
+ );
+}
+
+function createArtistClientSideCollectionItemsSelector(uiSection) {
+ return createDeepEqualSelector(
+ createUnoptimizedSelector(uiSection),
+ (artist) => artist
+ );
+}
+
+export default createArtistClientSideCollectionItemsSelector;
diff --git a/frontend/src/Store/Selectors/createArtistCountSelector.js b/frontend/src/Store/Selectors/createArtistCountSelector.js
new file mode 100644
index 000000000..203a5cb94
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistCountSelector.js
@@ -0,0 +1,17 @@
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createArtistCountSelector() {
+ return createSelector(
+ createAllArtistSelector(),
+ (state) => state.artist.error,
+ (artists, error) => {
+ return {
+ count: artists.length,
+ error
+ };
+ }
+ );
+}
+
+export default createArtistCountSelector;
diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js
new file mode 100644
index 000000000..de5205948
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js
@@ -0,0 +1,16 @@
+import { createSelector } from 'reselect';
+import createArtistSelector from './createArtistSelector';
+
+function createArtistMetadataProfileSelector() {
+ return createSelector(
+ (state) => state.settings.metadataProfiles.items,
+ createArtistSelector(),
+ (metadataProfiles, artist = {}) => {
+ return metadataProfiles.find((profile) => {
+ return profile.id === artist.metadataProfileId;
+ });
+ }
+ );
+}
+
+export default createArtistMetadataProfileSelector;
diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js
new file mode 100644
index 000000000..5819eb080
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js
@@ -0,0 +1,16 @@
+import { createSelector } from 'reselect';
+import createArtistSelector from './createArtistSelector';
+
+function createArtistQualityProfileSelector() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles.items,
+ createArtistSelector(),
+ (qualityProfiles, artist = {}) => {
+ return qualityProfiles.find((profile) => {
+ return profile.id === artist.qualityProfileId;
+ });
+ }
+ );
+}
+
+export default createArtistQualityProfileSelector;
diff --git a/frontend/src/Store/Selectors/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js
new file mode 100644
index 000000000..4b45118b8
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistSelector.js
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createArtistSelector() {
+ return createSelector(
+ (state, { artistId }) => artistId,
+ createAllArtistSelector(),
+ (artistId, allArtists) => {
+ return allArtists.find((artist) => artist.id === artistId );
+ }
+ );
+}
+
+export default createArtistSelector;
diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
new file mode 100644
index 000000000..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/createDeepEqualSelector.js b/frontend/src/Store/Selectors/createDeepEqualSelector.js
new file mode 100644
index 000000000..c01d23875
--- /dev/null
+++ b/frontend/src/Store/Selectors/createDeepEqualSelector.js
@@ -0,0 +1,9 @@
+import { createSelectorCreator, defaultMemoize } from 'reselect';
+import _ from 'lodash';
+
+const createDeepEqualSelector = createSelectorCreator(
+ defaultMemoize,
+ _.isEqual
+);
+
+export default createDeepEqualSelector;
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/createExecutingCommandsSelector.js b/frontend/src/Store/Selectors/createExecutingCommandsSelector.js
new file mode 100644
index 000000000..266865a8a
--- /dev/null
+++ b/frontend/src/Store/Selectors/createExecutingCommandsSelector.js
@@ -0,0 +1,13 @@
+import { createSelector } from 'reselect';
+import { isCommandExecuting } from 'Utilities/Command';
+
+function createExecutingCommandsSelector() {
+ return createSelector(
+ (state) => state.commands.items,
+ (commands) => {
+ return commands.filter((command) => isCommandExecuting(command));
+ }
+ );
+}
+
+export default createExecutingCommandsSelector;
diff --git a/frontend/src/Store/Selectors/createExistingArtistSelector.js b/frontend/src/Store/Selectors/createExistingArtistSelector.js
new file mode 100644
index 000000000..4811f2034
--- /dev/null
+++ b/frontend/src/Store/Selectors/createExistingArtistSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createExistingArtistSelector() {
+ return createSelector(
+ (state, { foreignArtistId }) => foreignArtistId,
+ createAllArtistSelector(),
+ (foreignArtistId, artist) => {
+ return _.some(artist, { foreignArtistId });
+ }
+ );
+}
+
+export default createExistingArtistSelector;
diff --git a/frontend/src/Store/Selectors/createImportArtistItemSelector.js b/frontend/src/Store/Selectors/createImportArtistItemSelector.js
new file mode 100644
index 000000000..6d72dc547
--- /dev/null
+++ b/frontend/src/Store/Selectors/createImportArtistItemSelector.js
@@ -0,0 +1,27 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createImportArtistItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ createAllArtistSelector(),
+ (id, addArtist, importArtist, artist) => {
+ const item = _.find(importArtist.items, { id }) || {};
+ const selectedArtist = item && item.selectedArtist;
+ const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId });
+
+ return {
+ defaultMonitor: addArtist.defaults.monitor,
+ defaultQualityProfileId: addArtist.defaults.qualityProfileId,
+ defaultAlbumFolder: addArtist.defaults.albumFolder,
+ ...item,
+ isExistingArtist
+ };
+ }
+ );
+}
+
+export default createImportArtistItemSelector;
diff --git a/frontend/src/Store/Selectors/createMetadataProfileSelector.js b/frontend/src/Store/Selectors/createMetadataProfileSelector.js
new file mode 100644
index 000000000..bdd0d0636
--- /dev/null
+++ b/frontend/src/Store/Selectors/createMetadataProfileSelector.js
@@ -0,0 +1,15 @@
+import { createSelector } from 'reselect';
+
+function createMetadataProfileSelector() {
+ return createSelector(
+ (state, { metadataProfileId }) => metadataProfileId,
+ (state) => state.settings.metadataProfiles.items,
+ (metadataProfileId, metadataProfiles) => {
+ return metadataProfiles.find((profile) => {
+ return profile.id === metadataProfileId;
+ });
+ }
+ );
+}
+
+export default createMetadataProfileSelector;
diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js
new file mode 100644
index 000000000..84fefb83e
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createProfileInUseSelector(profileProp) {
+ return createSelector(
+ (state, { id }) => id,
+ createAllArtistSelector(),
+ (state) => state.settings.importLists.items,
+ (id, artist, lists) => {
+ if (!id) {
+ return false;
+ }
+
+ if (_.some(artist, { [profileProp]: id }) || _.some(lists, { [profileProp]: id })) {
+ return true;
+ }
+
+ return false;
+ }
+ );
+}
+
+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..451aacfd4
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js
@@ -0,0 +1,15 @@
+import { createSelector } from 'reselect';
+
+function createQualityProfileSelector() {
+ return createSelector(
+ (state, { qualityProfileId }) => qualityProfileId,
+ (state) => state.settings.qualityProfiles.items,
+ (qualityProfileId, qualityProfiles) => {
+ return qualityProfiles.find((profile) => {
+ return profile.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..089795ced
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQueueItemSelector.js
@@ -0,0 +1,23 @@
+import { createSelector } from 'reselect';
+
+function createQueueItemSelector() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state) => state.queue.details.items,
+ (albumId, details) => {
+ if (!albumId) {
+ return null;
+ }
+
+ return details.find((item) => {
+ if (item.album) {
+ return item.album.id === albumId;
+ }
+
+ 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/createTrackFileSelector.js b/frontend/src/Store/Selectors/createTrackFileSelector.js
new file mode 100644
index 000000000..bcfc5cb0b
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTrackFileSelector.js
@@ -0,0 +1,17 @@
+import { createSelector } from 'reselect';
+
+function createTrackFileSelector() {
+ return createSelector(
+ (state, { trackFileId }) => trackFileId,
+ (state) => state.trackFiles,
+ (trackFileId, trackFiles) => {
+ if (!trackFileId) {
+ return;
+ }
+
+ return trackFiles.items.find((trackFile) => trackFile.id === trackFileId);
+ }
+ );
+}
+
+export default createTrackFileSelector;
diff --git a/frontend/src/Store/Selectors/createTrackSelector.js b/frontend/src/Store/Selectors/createTrackSelector.js
new file mode 100644
index 000000000..be57e6ca0
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTrackSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createTrackSelector() {
+ return createSelector(
+ (state, { trackId }) => trackId,
+ (state) => state.tracks,
+ (trackId, tracks) => {
+ return _.find(tracks.items, { id: trackId });
+ }
+ );
+}
+
+export default createTrackSelector;
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..4fef265f1
--- /dev/null
+++ b/frontend/src/Store/createAppStore.js
@@ -0,0 +1,15 @@
+import { createStore } from 'redux';
+import createReducers, { defaultState } from 'Store/Actions/createReducers';
+import middlewares from 'Store/Middleware/middlewares';
+
+function createAppStore(history) {
+ const appStore = createStore(
+ createReducers(history),
+ 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..287a58593
--- /dev/null
+++ b/frontend/src/Store/scrollPositions.js
@@ -0,0 +1,5 @@
+const scrollPositions = {
+ artistIndex: 0
+};
+
+export default scrollPositions;
diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js
new file mode 100644
index 000000000..6daa843f4
--- /dev/null
+++ b/frontend/src/Store/thunks.js
@@ -0,0 +1,27 @@
+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/src/UI/Shared/Styles/clickable.less b/frontend/src/Styles/Mixins/clickable.css
similarity index 100%
rename from src/UI/Shared/Styles/clickable.less
rename to frontend/src/Styles/Mixins/clickable.css
diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css
new file mode 100644
index 000000000..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..efcca0b57
--- /dev/null
+++ b/frontend/src/Styles/Mixins/scroller.css
@@ -0,0 +1,26 @@
+@define-mixin scrollbar {
+ &::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+}
+
+@define-mixin scrollbarTrack {
+ &&::-webkit-scrollbar-track {
+ background-color: transparent;
+ }
+}
+
+@define-mixin scrollbarThumb {
+ &::-webkit-scrollbar-thumb {
+ min-height: 100px;
+ 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..b9741a69a
--- /dev/null
+++ b/frontend/src/Styles/Variables/colors.js
@@ -0,0 +1,185 @@
+const lidarrGreen = '#00A65B';
+
+module.exports = {
+ defaultColor: '#333',
+ disabledColor: '#999',
+ dimColor: '#555',
+ black: '#000',
+ white: '#fff',
+ offWhite: '#f5f7fa',
+ blue: '#06f',
+ yellow: '#FFA500',
+ primaryColor: '#0b8750',
+ selectedColor: '#f9be03',
+ successColor: '#27c24c',
+ dangerColor: '#f05050',
+ warningColor: '#ffa500',
+ infoColor: lidarrGreen,
+ purple: '#7a43b6',
+ pink: '#ff69b4',
+ lidarrGreen,
+ helpTextColor: '#909293',
+ darkGray: '#888',
+ gray: '#adadad',
+ lightGray: '#ddd',
+ disabledInputColor: '#808080',
+
+ // Theme Colors
+
+ themeBlue: lidarrGreen,
+ themeAlternateBlue: '#00a65b',
+ themeRed: '#c4273c',
+ themeDarkColor: '#353535',
+ themeLightColor: '#1d563d',
+
+ torrentColor: '#00853d',
+ usenetColor: '#17b1d9',
+
+ // Links
+ defaultLinkHoverColor: '#fff',
+ linkColor: '#0b8750',
+ linkHoverColor: '#1b72e2',
+
+ // Sidebar
+
+ sidebarColor: '#e1e2e3',
+ sidebarBackgroundColor: '#353535',
+ sidebarActiveBackgroundColor: '#252525',
+
+ // Toolbar
+ toolbarColor: '#e1e2e3',
+ toolbarBackgroundColor: '#1d563d',
+ toolbarMenuItemBackgroundColor: '#4D8069',
+ toolbarMenuItemHoverBackgroundColor: '#353535',
+ toolbarLabelColor: '#8895aa',
+
+ // Accents
+ borderColor: '#e5e5e5',
+ inputBorderColor: '#dde6e9',
+ inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
+ inputFocusBorderColor: '#66afe9',
+ inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
+ inputErrorBorderColor: '#f05050',
+ inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)',
+ inputWarningBorderColor: '#ffa500',
+ inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)',
+ colorImpairedGradient: '#ffffff',
+ colorImpairedGradientDark: '#f4f5f6',
+
+ //
+ // Buttons
+
+ defaultBackgroundColor: '#fff',
+ defaultBorderColor: '#eaeaea',
+ defaultHoverBackgroundColor: '#f5f5f5',
+ defaultHoverBorderColor: '#d6d6d6;',
+
+ primaryBackgroundColor: '#0b8750',
+ primaryBorderColor: '#1d563d',
+ primaryHoverBackgroundColor: '#097948',
+ primaryHoverBorderColor: '#1D563D;',
+
+ successBackgroundColor: '#27c24c',
+ successBorderColor: '#26be4a',
+ successHoverBackgroundColor: '#24b145',
+ successHoverBorderColor: '#1f9c3d;',
+
+ warningBackgroundColor: '#ff902b',
+ warningBorderColor: '#ff8d26',
+ warningHoverBackgroundColor: '#ff8517',
+ warningHoverBorderColor: '#fc7800;',
+
+ dangerBackgroundColor: '#f05050',
+ dangerBorderColor: '#f04b4b',
+ dangerHoverBackgroundColor: '#ee3d3d',
+ dangerHoverBorderColor: '#ec2626;',
+
+ 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: '#00A65B',
+ toobarButtonSelectedColor: '#00A65B',
+
+ //
+ // Scroller
+
+ scrollbarBackgroundColor: '#9ea4b9',
+ scrollbarHoverBackgroundColor: '#656d8c',
+
+ //
+ // Card
+
+ cardShadowColor: '#e1e1e1',
+ cardAlternateBackgroundColor: '#f5f5f5',
+
+ //
+ // Alert
+
+ alertDangerBorderColor: '#ebccd1',
+ alertDangerBackgroundColor: '#f2dede',
+ alertDangerColor: '#a94442',
+
+ alertInfoBorderColor: '#bce8f1',
+ alertInfoBackgroundColor: '#d9edf7',
+ alertInfoColor: '#31708f',
+
+ alertSuccessBorderColor: '#d6e9c6',
+ alertSuccessBackgroundColor: '#dff0d8',
+ alertSuccessColor: '#3c763d',
+
+ alertWarningBorderColor: '#faebcc',
+ alertWarningBackgroundColor: '#fcf8e3',
+ alertWarningColor: '#8a6d3b',
+
+ //
+ // Slider
+
+ sliderAccentColor: '#0b8750',
+
+ //
+ // Form
+
+ advancedFormLabelColor: '#ff902b',
+ disabledCheckInputColor: '#ddd',
+
+ //
+ // Popover
+
+ popoverTitleBackgroundColor: '#f7f7f7',
+ popoverTitleBorderColor: '#ebebeb',
+ popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderColor: '#fff',
+
+ popoverTitleBackgroundInverseColor: '#3a3f51',
+ popoverTitleBorderInverseColor: '#353535',
+ popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)',
+
+ //
+ // Calendar
+
+ calendarTodayBackgroundColor: '#ddd',
+ calendarBorderColor: '#cecece',
+
+ //
+ // 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..db736f589
--- /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: '1310px',
+ 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',
+
+ // Artist
+ artistIndexColumnPadding: '20px',
+ artistIndexColumnPaddingSmallScreen: '10px',
+ artistIndexOverviewInfoRowHeight: '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/Variables/zIndexes.js b/frontend/src/Styles/Variables/zIndexes.js
new file mode 100644
index 000000000..986ceb548
--- /dev/null
+++ b/frontend/src/Styles/Variables/zIndexes.js
@@ -0,0 +1,4 @@
+module.exports = {
+ modalZIndex: 1000,
+ popperZIndex: 2000
+};
diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css
new file mode 100644
index 000000000..e630c77b9
--- /dev/null
+++ b/frontend/src/Styles/globals.css
@@ -0,0 +1,6 @@
+/* stylelint-disable */
+
+@import "~normalize.css/normalize.css";
+@import "scaffolding.css";
+
+/* stylelint-enable */
\ No newline at end of file
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..db805650e
--- /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..82113490e
--- /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..2775e8e08
--- /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..07dbde1c6
--- /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: Lidarr 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..b6e0dd24d
--- /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..8efd99abc
--- /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..d6751a5e5
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.js
@@ -0,0 +1,157 @@
+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..313f50cc0
--- /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..9886c7ad0
--- /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..12ec88157
--- /dev/null
+++ b/frontend/src/System/Status/About/About.js
@@ -0,0 +1,105 @@
+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,
+ isDocker,
+ runtimeVersion,
+ migrationVersion,
+ appData,
+ startupPath,
+ mode,
+ startTime,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isMonoRuntime &&
+
+ }
+
+ {
+ isDocker &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+ );
+ }
+
+}
+
+About.propTypes = {
+ version: PropTypes.string.isRequired,
+ isMonoRuntime: PropTypes.bool.isRequired,
+ runtimeVersion: PropTypes.string.isRequired,
+ isDocker: PropTypes.bool.isRequired,
+ migrationVersion: PropTypes.number.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..dd92926d4
--- /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..adb5bb853
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js
@@ -0,0 +1,122 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './DiskSpace.css';
+
+const columns = [
+ {
+ name: 'path',
+ label: 'Location',
+ isVisible: true
+ },
+ {
+ name: 'freeSpace',
+ label: 'Free Space',
+ isVisible: true
+ },
+ {
+ name: 'totalSpace',
+ label: 'Total Space',
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ isVisible: true
+ }
+];
+
+class DiskSpace extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items
+ } = this.props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching &&
+
+
+ {
+ items.map((item) => {
+ const {
+ freeSpace,
+ totalSpace
+ } = item;
+
+ const diskUsage = (100 - freeSpace / totalSpace * 100);
+ let diskUsageKind = kinds.PRIMARY;
+
+ if (diskUsage > 90) {
+ diskUsageKind = kinds.DANGER;
+ } else if (diskUsage > 80) {
+ diskUsageKind = kinds.WARNING;
+ }
+
+ return (
+
+
+ {item.path}
+
+ {
+ item.label &&
+ ` (${item.label})`
+ }
+
+
+
+ {formatBytes(freeSpace)}
+
+
+
+ {formatBytes(totalSpace)}
+
+
+
+
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+DiskSpace.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default DiskSpace;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
new file mode 100644
index 000000000..3049b2ead
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDiskSpace } from 'Store/Actions/systemActions';
+import DiskSpace from './DiskSpace';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.diskSpace,
+ (diskSpace) => {
+ const {
+ isFetching,
+ items
+ } = diskSpace;
+
+ return {
+ isFetching,
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDiskSpace
+};
+
+class DiskSpaceConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDiskSpace();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DiskSpaceConnector.propTypes = {
+ fetchDiskSpace: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector);
diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css
new file mode 100644
index 000000000..1c4cb6a9d
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.css
@@ -0,0 +1,20 @@
+.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..b99075704
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.js
@@ -0,0 +1,223 @@
+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 'DownloadClientStatusCheck':
+ case 'ImportMechanismCheck':
+ case 'RemotePathMappingCheck':
+ return (
+
+ );
+ case 'RootFolderCheck':
+ return (
+
+ );
+ case 'UpdateCheck':
+ return (
+
+ );
+ default:
+ return;
+ }
+}
+
+function getTestLink(source, props) {
+ switch (source) {
+ case 'IndexerStatusCheck':
+ return (
+
+ );
+ case 'DownloadClientCheck':
+ case 'DownloadClientStatusCheck':
+ 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);
+
+ let kind = kinds.WARNING;
+ switch (item.type.toLowerCase()) {
+ case 'error':
+ kind = kinds.DANGER;
+ break;
+ default:
+ case 'warning':
+ kind = kinds.WARNING;
+ break;
+ case 'notice':
+ kind = kinds.INFO;
+ break;
+ }
+
+ 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..40286e27b
--- /dev/null
+++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js
@@ -0,0 +1,67 @@
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import FieldSet from 'Components/FieldSet';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+
+class MoreInfo extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+ Home page
+
+ lidarr.audio
+
+
+ Wiki
+
+ wiki.lidarr.audio
+
+
+ Reddit
+
+ Lidarr
+
+
+ Discord
+
+ #lidarr on Discord
+
+
+ Donations
+
+ Donate to Lidarr
+
+
+ Sonarr Donations
+
+ Donate to Sonarr
+
+
+ Source
+
+ github.com/Lidarr/Lidarr
+
+
+ Feature Requests
+
+ github.com/Lidarr/Lidarr/issues
+
+
+
+
+ );
+ }
+}
+
+MoreInfo.propTypes = {
+
+};
+
+export default MoreInfo;
diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js
new file mode 100644
index 000000000..f0b157515
--- /dev/null
+++ b/frontend/src/System/Status/Status.js
@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import HealthConnector from './Health/HealthConnector';
+import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector';
+import AboutConnector from './About/AboutConnector';
+import MoreInfo from './MoreInfo/MoreInfo';
+
+class Status extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default Status;
diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css
new file mode 100644
index 000000000..6e38929c9
--- /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: 60px;
+}
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..924963258
--- /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..f563cb743
--- /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;
+}
+
+.label {
+ 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..d35ecd23a
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.js
@@ -0,0 +1,195 @@
+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 {
+ currentVersion,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ isDocker,
+ 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 &&
+
+ {
+ !isDocker &&
+
+ Install Latest
+
+ }
+
+ {
+ isDocker &&
+
+ An update is available. Please update your Docker image and re-create the container.
+
+ }
+
+ {
+ isFetching &&
+
+ }
+
+ }
+
+ {
+ noUpdateToInstall &&
+
+
+
+ The latest version of Lidarr is already installed
+
+
+ {
+ isFetching &&
+
+ }
+
+ }
+
+ {
+ hasUpdates &&
+
+ {
+ items.map((update) => {
+ const hasChanges = !!update.changes;
+
+ return (
+
+
+
{update.version}
+
—
+
{formatDate(update.releaseDate, shortDateFormat)}
+
+ {
+ update.branch === 'master' ?
+ null :
+
+ {update.branch}
+
+ }
+
+ {
+ update.version === currentVersion ?
+
+ Currently Installed
+ :
+ null
+ }
+
+
+ {
+ !hasChanges &&
+
Maintenance release
+ }
+
+ {
+ hasChanges &&
+
+
+
+
+
+ }
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!error &&
+
+ Failed to fetch updates
+
+ }
+
+
+ );
+ }
+
+}
+
+Updates.propTypes = {
+ currentVersion: PropTypes.string.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ isInstallingUpdate: PropTypes.bool.isRequired,
+ isDocker: 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..7c40069d4
--- /dev/null
+++ b/frontend/src/System/Updates/UpdatesConnector.js
@@ -0,0 +1,87 @@
+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 createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import * as commandNames from 'Commands/commandNames';
+import Updates from './Updates';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.version,
+ (state) => state.system.updates,
+ createUISettingsSelector(),
+ createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
+ createSystemStatusSelector(),
+ (
+ currentVersion,
+ updates,
+ uiSettings,
+ isInstallingUpdate,
+ systemStatus
+ ) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = updates;
+
+ return {
+ currentVersion,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ isDocker: systemStatus.isDocker,
+ 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/TrackFile/Editor/TrackFileEditorModal.js b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js
new file mode 100644
index 000000000..7f52aca05
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import TrackFileEditorModalContentConnector from './TrackFileEditorModalContentConnector';
+
+function TrackFileEditorModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+TrackFileEditorModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorModal;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css
new file mode 100644
index 000000000..49e946826
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css
@@ -0,0 +1,8 @@
+.actions {
+ display: flex;
+ margin-right: auto;
+}
+
+.selectInput {
+ margin-left: 10px;
+}
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js
new file mode 100644
index 000000000..f9d9cc282
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js
@@ -0,0 +1,262 @@
+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 { kinds } from 'Helpers/Props';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 TrackFileEditorRow from './TrackFileEditorRow';
+import styles from './TrackFileEditorModalContent.css';
+
+const columns = [
+ {
+ name: 'trackNumber',
+ label: 'Track',
+ isVisible: true
+ },
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ }
+];
+
+class TrackFileEditorModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // 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.trackFileId)) {
+ acc.push(matchingItem.trackFileId);
+ }
+
+ 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 });
+ }
+
+ onQualityChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onQualityChange(selectedIds, parseInt(value));
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isDeleting,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ qualities,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ 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 Tracks
+
+
+
+ {
+ isFetching && !isPopulated ?
+ :
+ null
+ }
+
+ {
+ !isFetching && error ?
+ {error}
:
+ null
+ }
+
+ {
+ isPopulated && !items.length ?
+
+ No track files to manage.
+
:
+ null
+ }
+
+ {
+ isPopulated && items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+ null
+ }
+
+
+
+
+
+
+ Close
+
+
+
+
+
+ );
+ }
+}
+
+TrackFileEditorModalContent.propTypes = {
+ isDeleting: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onQualityChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorModalContent;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js
new file mode 100644
index 000000000..bfd90a44b
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js
@@ -0,0 +1,173 @@
+/* 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 createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions';
+import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import TrackFileEditorModalContent from './TrackFileEditorModalContent';
+
+function createSchemaSelector() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const qualities = getQualities(qualityProfiles.schema.items);
+
+ let error = null;
+
+ if (qualityProfiles.schemaError) {
+ error = 'Unable to load qualities';
+ }
+
+ return {
+ isFetching: qualityProfiles.isSchemaFetching,
+ isPopulated: qualityProfiles.isSchemaPopulated,
+ error,
+ qualities
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state) => state.tracks,
+ (state) => state.trackFiles,
+ createSchemaSelector(),
+ createArtistSelector(),
+ (
+ albumId,
+ tracks,
+ trackFiles,
+ schema,
+ artist
+ ) => {
+ const filtered = _.filter(tracks.items, (track) => {
+ if (albumId >= 0 && track.albumId !== albumId) {
+ return false;
+ }
+
+ if (!track.trackFileId) {
+ return false;
+ }
+
+ return _.some(trackFiles.items, { id: track.trackFileId });
+ });
+
+ const sorted = _.orderBy(filtered, ['albumId', 'absoluteTrackNumber'], ['desc', 'asc']);
+
+ const items = _.map(sorted, (track) => {
+ const trackFile = _.find(trackFiles.items, { id: track.trackFileId });
+
+ return {
+ relativePath: trackFile.relativePath,
+ quality: trackFile.quality,
+ ...track
+ };
+ });
+
+ return {
+ ...schema,
+ items,
+ artistType: artist.artistType,
+ isDeleting: trackFiles.isDeleting,
+ isSaving: trackFiles.isSaving
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchClearTracks() {
+ dispatch(clearTracks());
+ },
+
+ dispatchFetchTracks(updateProps) {
+ dispatch(fetchTracks(updateProps));
+ },
+
+ dispatchFetchQualityProfileSchema(name, path) {
+ dispatch(fetchQualityProfileSchema());
+ },
+
+ dispatchUpdateTrackFiles(updateProps) {
+ dispatch(updateTrackFiles(updateProps));
+ },
+
+ onDeletePress(trackFileIds) {
+ dispatch(deleteTrackFiles({ trackFileIds }));
+ }
+ };
+}
+
+class TrackFileEditorModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const artistId = this.props.artistId;
+ const albumId = this.props.albumId;
+
+ this.props.dispatchFetchTracks({ artistId, albumId });
+
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearTracks();
+ }
+
+ //
+ // Listeners
+
+ onQualityChange = (trackFileIds, qualityId) => {
+ const quality = {
+ quality: _.find(this.props.qualities, { id: qualityId }),
+ revision: {
+ version: 1,
+ real: 0
+ }
+ };
+
+ this.props.dispatchUpdateTrackFiles({ trackFileIds, quality });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchQualityProfileSchema,
+ dispatchUpdateTrackFiles,
+ dispatchFetchTracks,
+ dispatchClearTracks,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+TrackFileEditorModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dispatchFetchTracks: PropTypes.func.isRequired,
+ dispatchClearTracks: PropTypes.func.isRequired,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
+ dispatchUpdateTrackFiles: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TrackFileEditorModalContentConnector);
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js
new file mode 100644
index 000000000..e475c115b
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import padNumber from 'Utilities/Number/padNumber';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import TrackQuality from 'Album/TrackQuality';
+
+function TrackFileEditorRow(props) {
+ const {
+ id,
+ trackNumber,
+ relativePath,
+ quality,
+ isSelected,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+
+ {padNumber(trackNumber, 2)}
+
+
+
+ {relativePath}
+
+
+
+
+
+
+ );
+}
+
+TrackFileEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ trackNumber: PropTypes.string.isRequired,
+ relativePath: PropTypes.string.isRequired,
+ quality: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorRow;
diff --git a/frontend/src/TrackFile/ExpandingFileDetails.css b/frontend/src/TrackFile/ExpandingFileDetails.css
new file mode 100644
index 000000000..d0bd945f8
--- /dev/null
+++ b/frontend/src/TrackFile/ExpandingFileDetails.css
@@ -0,0 +1,61 @@
+.fileDetails {
+ margin-bottom: 20px;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.filename {
+ flex-grow: 1;
+ margin-right: 10px;
+ margin-left: 10px;
+ font-size: 14px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-size: 18px;
+}
+
+.expandButton {
+ position: relative;
+ width: 60px;
+ height: 60px;
+}
+
+.actionButton {
+ composes: button from '~Components/Link/IconButton.css';
+
+ width: 30px;
+}
+
+.expandButtonIcon {
+ composes: actionButton;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -12px;
+ margin-left: -15px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .medium {
+ border-right: 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+
+ .expandButtonIcon {
+ position: static;
+ margin: 0;
+ }
+}
diff --git a/frontend/src/TrackFile/ExpandingFileDetails.js b/frontend/src/TrackFile/ExpandingFileDetails.js
new file mode 100644
index 000000000..aa1a9ab91
--- /dev/null
+++ b/frontend/src/TrackFile/ExpandingFileDetails.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FileDetails from './FileDetails';
+import styles from './ExpandingFileDetails.css';
+
+class ExpandingFileDetails extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isExpanded: props.isExpanded
+ };
+ }
+
+ //
+ // Listeners
+
+ onExpandPress = () => {
+ const {
+ isExpanded
+ } = this.state;
+ this.setState({ isExpanded: !isExpanded });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ filename,
+ audioTags,
+ rejections
+ } = this.props;
+
+ const {
+ isExpanded
+ } = this.state;
+
+ return (
+
+
+
+ {filename}
+
+
+
+
+
+
+
+ {
+ isExpanded &&
+
+ }
+
+ );
+ }
+}
+
+ExpandingFileDetails.propTypes = {
+ audioTags: PropTypes.object.isRequired,
+ filename: PropTypes.string.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object),
+ isExpanded: PropTypes.bool
+};
+
+export default ExpandingFileDetails;
diff --git a/frontend/src/TrackFile/FileDetails.css b/frontend/src/TrackFile/FileDetails.css
new file mode 100644
index 000000000..f3e51ea39
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetails.css
@@ -0,0 +1,11 @@
+.audioTags {
+ padding-top: 15px;
+ padding-bottom: 15px;
+ /* border-top: 1px solid $borderColor; */
+}
+
+.filename {
+ composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css';
+
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/TrackFile/FileDetails.js b/frontend/src/TrackFile/FileDetails.js
new file mode 100644
index 000000000..725a1f0a4
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetails.js
@@ -0,0 +1,206 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Fragment } from 'react';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+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 './FileDetails.css';
+
+function renderRejections(rejections) {
+ return (
+
+
+ Rejections
+
+ {
+ _.map(rejections, (item, key) => {
+ return (
+
+ {item.reason}
+
+ );
+ })
+ }
+
+ );
+}
+
+function FileDetails(props) {
+
+ const {
+ filename,
+ audioTags,
+ rejections
+ } = props;
+
+ return (
+
+
+
+ {
+ filename &&
+
+ }
+ {
+ audioTags.title !== undefined &&
+
+ }
+ {
+ audioTags.trackNumbers[0] > 0 &&
+
+ }
+ {
+ audioTags.discNumber > 0 &&
+
+ }
+ {
+ audioTags.discCount > 0 &&
+
+ }
+ {
+ audioTags.albumTitle !== undefined &&
+
+ }
+ {
+ audioTags.artistTitle !== undefined &&
+
+ }
+ {
+ audioTags.country !== undefined &&
+
+ }
+ {
+ audioTags.year > 0 &&
+
+ }
+ {
+ audioTags.label !== undefined &&
+
+ }
+ {
+ audioTags.catalogNumber !== undefined &&
+
+ }
+ {
+ audioTags.disambiguation !== undefined &&
+
+ }
+ {
+ audioTags.duration !== undefined &&
+
+ }
+ {
+ audioTags.artistMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.albumMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.releaseMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.recordingMBId !== undefined &&
+
+
+
+ }
+ {
+ audioTags.trackMBId !== undefined &&
+
+
+
+ }
+ {
+ !!rejections && rejections.length > 0 &&
+ renderRejections(rejections)
+ }
+
+
+
+ );
+}
+
+FileDetails.propTypes = {
+ filename: PropTypes.string,
+ audioTags: PropTypes.object.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.object)
+};
+
+export default FileDetails;
diff --git a/frontend/src/TrackFile/FileDetailsConnector.js b/frontend/src/TrackFile/FileDetailsConnector.js
new file mode 100644
index 000000000..f52dbcacd
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetailsConnector.js
@@ -0,0 +1,77 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import { fetchTrackFiles } from 'Store/Actions/trackFileActions';
+import FileDetails from './FileDetails';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.trackFiles,
+ (trackFiles) => {
+ return {
+ ...trackFiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchTrackFiles
+};
+
+class FileDetailsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchTrackFiles({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ id,
+ isFetching,
+ error
+ } = this.props;
+
+ const item = _.find(items, { id });
+ const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
+
+ if (isFetching || !item.audioTags) {
+ return (
+
+ );
+ } else if (error) {
+ return (
+ {errorMessage}
+ );
+ }
+
+ return (
+
+ );
+
+ }
+}
+
+FileDetailsConnector.propTypes = {
+ fetchTrackFiles: PropTypes.func.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ id: PropTypes.number.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FileDetailsConnector);
diff --git a/frontend/src/TrackFile/FileDetailsModal.js b/frontend/src/TrackFile/FileDetailsModal.js
new file mode 100644
index 000000000..e9677b647
--- /dev/null
+++ b/frontend/src/TrackFile/FileDetailsModal.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import FileDetailsConnector from './FileDetailsConnector';
+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 FileDetailsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ id
+ } = props;
+
+ return (
+
+
+
+ Details
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ );
+}
+
+FileDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ id: PropTypes.number.isRequired
+};
+
+export default FileDetailsModal;
diff --git a/frontend/src/TrackFile/MediaInfo.js b/frontend/src/TrackFile/MediaInfo.js
new file mode 100644
index 000000000..3f50fb70e
--- /dev/null
+++ b/frontend/src/TrackFile/MediaInfo.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import * as mediaInfoTypes from './mediaInfoTypes';
+
+function MediaInfo(props) {
+ const {
+ type,
+ audioChannels,
+ audioCodec,
+ audioBitRate,
+ audioBits,
+ audioSampleRate
+ } = props;
+
+ if (type === mediaInfoTypes.AUDIO) {
+ return (
+
+ {
+ !!audioCodec &&
+ audioCodec
+ }
+
+ {
+ !!audioCodec && !!audioChannels &&
+ ' - '
+ }
+
+ {
+ !!audioChannels &&
+ audioChannels.toFixed(1)
+ }
+
+ {
+ ((!!audioCodec && !!audioBitRate) || (!!audioChannels && !!audioBitRate)) &&
+ ' - '
+ }
+
+ {
+ !!audioBitRate &&
+ audioBitRate
+ }
+
+ {
+ ((!!audioCodec && !!audioSampleRate) || (!!audioChannels && !!audioSampleRate) || (!!audioBitRate && !!audioSampleRate)) &&
+ ' - '
+ }
+
+ {
+ !!audioSampleRate &&
+ audioSampleRate
+ }
+
+ {
+ ((!!audioCodec && !!audioBits) || (!!audioChannels && !!audioBits) || (!!audioBitRate && !!audioBits) || (!!audioSampleRate && !!audioBits)) &&
+ ' - '
+ }
+
+ {
+ !!audioBits &&
+ audioBits
+ }
+
+ );
+ }
+
+ return null;
+}
+
+MediaInfo.propTypes = {
+ type: PropTypes.string.isRequired,
+ audioChannels: PropTypes.number,
+ audioCodec: PropTypes.string,
+ audioBitRate: PropTypes.string,
+ audioBits: PropTypes.string,
+ audioSampleRate: PropTypes.string
+};
+
+export default MediaInfo;
diff --git a/frontend/src/TrackFile/MediaInfoConnector.js b/frontend/src/TrackFile/MediaInfoConnector.js
new file mode 100644
index 000000000..5f3a1386b
--- /dev/null
+++ b/frontend/src/TrackFile/MediaInfoConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import MediaInfo from './MediaInfo';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTrackFileSelector(),
+ (trackFile) => {
+ if (trackFile) {
+ return {
+ ...trackFile.mediaInfo
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MediaInfo);
diff --git a/frontend/src/TrackFile/mediaInfoTypes.js b/frontend/src/TrackFile/mediaInfoTypes.js
new file mode 100644
index 000000000..5e5a78e64
--- /dev/null
+++ b/frontend/src/TrackFile/mediaInfoTypes.js
@@ -0,0 +1,2 @@
+export const AUDIO = 'audio';
+export const VIDEO = 'video';
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js
new file mode 100644
index 000000000..e6a02fee6
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js
@@ -0,0 +1,161 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import VirtualTable from 'Components/Table/VirtualTable';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+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 UnmappedFilesTableRow from './UnmappedFilesTableRow';
+import UnmappedFilesTableHeader from './UnmappedFilesTableHeader';
+
+class UnmappedFilesTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ contentBody: null,
+ scrollTop: 0
+ };
+ }
+
+ //
+ // Control
+
+ setContentBodyRef = (ref) => {
+ this.setState({ contentBody: ref });
+ }
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ columns,
+ deleteUnmappedFile
+ } = this.props;
+
+ const item = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onScroll = ({ scrollTop }) => {
+ this.setState({ scrollTop });
+ }
+
+ render() {
+
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ sortKey,
+ sortDirection,
+ onTableOptionChange,
+ onSortPress,
+ deleteUnmappedFile,
+ ...otherProps
+ } = this.props;
+
+ const {
+ scrollTop,
+ contentBody
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ Success! My work is done, all files on disk are matched to known tracks.
+
+ }
+
+ {
+ isPopulated && !error && !!items.length && contentBody &&
+
+ }
+ sortKey={sortKey}
+ sortDirection={sortDirection}
+ />
+ }
+
+
+ );
+ }
+}
+
+UnmappedFilesTable.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ onTableOptionChange: PropTypes.func.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ deleteUnmappedFile: PropTypes.func.isRequired
+};
+
+export default UnmappedFilesTable;
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js
new file mode 100644
index 000000000..2f9dffd63
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js
@@ -0,0 +1,100 @@
+import _ from 'lodash';
+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 createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { fetchTrackFiles, deleteTrackFile, setTrackFilesSort, setTrackFilesTableOption } from 'Store/Actions/trackFileActions';
+import withCurrentPage from 'Components/withCurrentPage';
+import UnmappedFilesTable from './UnmappedFilesTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector('trackFiles'),
+ createDimensionsSelector(),
+ (
+ trackFiles,
+ dimensionsState
+ ) => {
+ // trackFiles could pick up mapped entries via signalR so filter again here
+ const {
+ items,
+ ...otherProps
+ } = trackFiles;
+ const unmappedFiles = _.filter(items, { albumId: 0 });
+ return {
+ items: unmappedFiles,
+ ...otherProps,
+ isSmallScreen: dimensionsState.isSmallScreen
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setTrackFilesTableOption(payload));
+ },
+
+ onSortPress(sortKey) {
+ dispatch(setTrackFilesSort({ sortKey }));
+ },
+
+ fetchUnmappedFiles() {
+ dispatch(fetchTrackFiles({ unmapped: true }));
+ },
+
+ deleteUnmappedFile(id) {
+ dispatch(deleteTrackFile({ id }));
+ }
+ };
+}
+
+class UnmappedFilesTableConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate, ['trackFileUpdated']);
+
+ this.repopulate();
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchUnmappedFiles();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UnmappedFilesTableConnector.propTypes = {
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ fetchUnmappedFiles: PropTypes.func.isRequired,
+ deleteUnmappedFile: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, createMapDispatchToProps)(UnmappedFilesTableConnector)
+);
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css
new file mode 100644
index 000000000..cd8c47183
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css
@@ -0,0 +1,19 @@
+.quality,
+.size,
+.dateAdded {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 120px;
+}
+
+.path {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 4 0 400px;
+}
+
+.actions {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 90px;
+}
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js
new file mode 100644
index 000000000..0d5701c9c
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React 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 TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+// import hasGrowableColumns from './hasGrowableColumns';
+import styles from './UnmappedFilesTableHeader.css';
+
+function UnmappedFilesTableHeader(props) {
+ const {
+ columns,
+ onTableOptionChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ label,
+ isSortable,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {label}
+
+ );
+ })
+ }
+
+ );
+}
+
+UnmappedFilesTableHeader.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default UnmappedFilesTableHeader;
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css
new file mode 100644
index 000000000..f82ac9889
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css
@@ -0,0 +1,22 @@
+.path {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 4 0 400px;
+ font-size: 13px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.quality,
+.dateAdded,
+.size {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 120px;
+ white-space: nowrap;
+}
+
+.actions {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 90px;
+}
diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js
new file mode 100644
index 000000000..6806f5468
--- /dev/null
+++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js
@@ -0,0 +1,218 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import VirtualTableRow from 'Components/Table/VirtualTableRow';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import TrackQuality from 'Album/TrackQuality';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import FileDetailsModal from 'TrackFile/FileDetailsModal';
+import styles from './UnmappedFilesTableRow.css';
+
+class UnmappedFilesTableRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false,
+ isInteractiveImportModalOpen: false,
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ onDeleteFilePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ }
+
+ onConfirmDelete = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ this.props.deleteUnmappedFile(this.props.id);
+ }
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ path,
+ size,
+ dateAdded,
+ quality,
+ columns
+ } = this.props;
+
+ const folder = path.substring(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')));
+
+ const {
+ isInteractiveImportModalOpen,
+ isDetailsModalOpen,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (name === 'size') {
+ return (
+
+ {formatBytes(size)}
+
+ );
+ }
+
+ if (name === 'dateAdded') {
+ return (
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+UnmappedFilesTableRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ quality: PropTypes.object.isRequired,
+ dateAdded: PropTypes.string.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ deleteUnmappedFile: PropTypes.func.isRequired
+};
+
+export default UnmappedFilesTableRow;
diff --git a/frontend/src/Utilities/Album/updateAlbums.js b/frontend/src/Utilities/Album/updateAlbums.js
new file mode 100644
index 000000000..259ef510e
--- /dev/null
+++ b/frontend/src/Utilities/Album/updateAlbums.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { update } from 'Store/Actions/baseActions';
+
+function updateAlbums(section, albums, albumIds, options) {
+ const data = _.reduce(albums, (result, item) => {
+ if (albumIds.indexOf(item.id) > -1) {
+ result.push({
+ ...item,
+ ...options
+ });
+ } else {
+ result.push(item);
+ }
+
+ return result;
+ }, []);
+
+ return update({ section, data });
+}
+
+export default updateAlbums;
diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js
new file mode 100644
index 000000000..d27cfd604
--- /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.sortName.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/Artist/getNewArtist.js b/frontend/src/Utilities/Artist/getNewArtist.js
new file mode 100644
index 000000000..d39855f04
--- /dev/null
+++ b/frontend/src/Utilities/Artist/getNewArtist.js
@@ -0,0 +1,31 @@
+
+function getNewArtist(artist, payload) {
+ const {
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ metadataProfileId,
+ artistType,
+ albumFolder,
+ tags,
+ searchForMissingAlbums = false
+ } = payload;
+
+ const addOptions = {
+ monitor,
+ searchForMissingAlbums
+ };
+
+ artist.addOptions = addOptions;
+ artist.monitored = true;
+ artist.qualityProfileId = qualityProfileId;
+ artist.metadataProfileId = metadataProfileId;
+ artist.rootFolderPath = rootFolderPath;
+ artist.artistType = artistType;
+ artist.albumFolder = albumFolder;
+ artist.tags = tags;
+
+ return artist;
+}
+
+export default getNewArtist;
diff --git a/frontend/src/Utilities/Artist/getProgressBarKind.js b/frontend/src/Utilities/Artist/getProgressBarKind.js
new file mode 100644
index 000000000..eb3b2dd6e
--- /dev/null
+++ b/frontend/src/Utilities/Artist/getProgressBarKind.js
@@ -0,0 +1,15 @@
+import { kinds } from 'Helpers/Props';
+
+function getProgressBarKind(status, monitored, progress) {
+ if (progress === 100) {
+ return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY;
+ }
+
+ if (monitored) {
+ return kinds.DANGER;
+ }
+
+ return kinds.WARNING;
+}
+
+export default getProgressBarKind;
diff --git a/frontend/src/Utilities/Artist/monitorOptions.js b/frontend/src/Utilities/Artist/monitorOptions.js
new file mode 100644
index 000000000..b5e942ae6
--- /dev/null
+++ b/frontend/src/Utilities/Artist/monitorOptions.js
@@ -0,0 +1,11 @@
+const monitorOptions = [
+ { key: 'all', value: 'All Albums' },
+ { key: 'future', value: 'Future Albums' },
+ { key: 'missing', value: 'Missing Albums' },
+ { key: 'existing', value: 'Existing Albums' },
+ { key: 'first', value: 'Only First Album' },
+ { key: 'latest', value: 'Only Latest Album' },
+ { key: 'none', value: 'None' }
+];
+
+export default monitorOptions;
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/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js
new file mode 100644
index 000000000..1c104073c
--- /dev/null
+++ b/frontend/src/Utilities/Filter/findSelectedFilters.js
@@ -0,0 +1,19 @@
+export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) {
+ if (!selectedFilterKey) {
+ return [];
+ }
+
+ let selectedFilter = filters.find((f) => f.key === selectedFilterKey);
+
+ if (!selectedFilter) {
+ selectedFilter = customFilters.find((f) => f.id === selectedFilterKey);
+ }
+
+ if (!selectedFilter) {
+ // TODO: throw in dev
+ console.error('Matching filter not found');
+ return [];
+ }
+
+ return selectedFilter.filters;
+}
diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js
new file mode 100644
index 000000000..70b0b51f1
--- /dev/null
+++ b/frontend/src/Utilities/Filter/getFilterValue.js
@@ -0,0 +1,11 @@
+export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) {
+ const filter = filters.find((f) => f.key === filterKey);
+
+ if (!filter) {
+ return defaultValue;
+ }
+
+ const filterValue = filter.filters.find((f) => f.key === filterValueKey);
+
+ return filterValue ? filterValue.value : defaultValue;
+}
diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js
new file mode 100644
index 000000000..88357944f
--- /dev/null
+++ b/frontend/src/Utilities/Number/convertToBytes.js
@@ -0,0 +1,15 @@
+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..7bae5367b
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatBytes.js
@@ -0,0 +1,17 @@
+import filesize from 'filesize';
+
+function formatBytes(input, showBits = false) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ return filesize(size, {
+ base: 2,
+ round: 1,
+ bits: showBits
+ });
+}
+
+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..1b33f5a04
--- /dev/null
+++ b/frontend/src/Utilities/createAjaxRequest.js
@@ -0,0 +1,66 @@
+import $ from 'jquery';
+
+const absUrlRegex = /^(https?:)?\/\//i;
+const apiRoot = window.Lidarr.apiRoot;
+
+function isRelative(ajaxOptions) {
+ return !absUrlRegex.test(ajaxOptions.url);
+}
+
+function moveBodyToQuery(ajaxOptions) {
+ if (ajaxOptions.data && ajaxOptions.type === 'DELETE') {
+ if (ajaxOptions.url.contains('?')) {
+ ajaxOptions.url += '&';
+ } else {
+ ajaxOptions.url += '?';
+ }
+ ajaxOptions.url += $.param(ajaxOptions.data);
+ delete ajaxOptions.data;
+ }
+}
+
+function addRootUrl(ajaxOptions) {
+ ajaxOptions.url = apiRoot + ajaxOptions.url;
+}
+
+function addApiKey(ajaxOptions) {
+ ajaxOptions.headers = ajaxOptions.headers || {};
+ ajaxOptions.headers['X-Api-Key'] = window.Lidarr.apiKey;
+}
+
+export default function createAjaxRequest(originalAjaxOptions) {
+ const requestXHR = new window.XMLHttpRequest();
+ let aborted = false;
+ let complete = false;
+
+ function abortRequest() {
+ if (!complete) {
+ aborted = true;
+ requestXHR.abort();
+ }
+ }
+
+ const ajaxOptions = { ...originalAjaxOptions };
+
+ if (isRelative(ajaxOptions)) {
+ moveBodyToQuery(ajaxOptions);
+ addRootUrl(ajaxOptions);
+ addApiKey(ajaxOptions);
+ }
+
+ 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..292e98dba
--- /dev/null
+++ b/frontend/src/Utilities/getPathWithUrlBase.js
@@ -0,0 +1,3 @@
+export default function getPathWithUrlBase(path) {
+ return `${window.Lidarr.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/mobile.js b/frontend/src/Utilities/mobile.js
new file mode 100644
index 000000000..e52975963
--- /dev/null
+++ b/frontend/src/Utilities/mobile.js
@@ -0,0 +1,12 @@
+import MobileDetect from 'mobile-detect';
+
+const mobileDetect = new MobileDetect(window.navigator.userAgent);
+
+export function isMobile() {
+
+ return mobileDetect.mobile() != null;
+}
+
+export function isIOS() {
+ return mobileDetect.is('iOS');
+}
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..ed69cf5ad
--- /dev/null
+++ b/frontend/src/Utilities/requestAction.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import _ from 'lodash';
+import createAjaxRequest from './createAjaxRequest';
+
+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 createAjaxRequest(ajaxOptions).request;
+}
+
+export default requestAction;
diff --git a/frontend/src/Utilities/scrollLock.js b/frontend/src/Utilities/scrollLock.js
new file mode 100644
index 000000000..cff50a34b
--- /dev/null
+++ b/frontend/src/Utilities/scrollLock.js
@@ -0,0 +1,13 @@
+// Allow iOS devices to disable scrolling of the body/virtual table
+// when a modal is open. This will prevent focusing an input in a
+// modal causing the modal to close due to scrolling.
+
+let scrollLock = false;
+
+export function isLocked() {
+ return scrollLock;
+}
+
+export function setScrollLock(locked) {
+ scrollLock = locked;
+}
diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js
new file mode 100644
index 000000000..5479b32b9
--- /dev/null
+++ b/frontend/src/Utilities/sectionTypes.js
@@ -0,0 +1,6 @@
+const sectionTypes = {
+ COLLECTION: 'collection',
+ MODEL: 'model'
+};
+
+export default sectionTypes;
diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js
new file mode 100644
index 000000000..03fa39c00
--- /dev/null
+++ b/frontend/src/Utilities/serverSideCollectionHandlers.js
@@ -0,0 +1,12 @@
+const serverSideCollectionHandlers = {
+ FETCH: 'fetch',
+ FIRST_PAGE: 'firstPage',
+ PREVIOUS_PAGE: 'previousPage',
+ NEXT_PAGE: 'nextPage',
+ LAST_PAGE: 'lastPage',
+ EXACT_PAGE: 'exactPage',
+ SORT: 'sort',
+ FILTER: 'filter'
+};
+
+export default serverSideCollectionHandlers;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
new file mode 100644
index 000000000..eca14e349
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
@@ -0,0 +1,276 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import CutoffUnmetRowConnector from './CutoffUnmetRowConnector';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class CutoffUnmet extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllCutoffUnmetModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ }
+
+ onToggleSelectedPress = () => {
+ const albumIds = this.getSelectedIds();
+
+ this.props.batchToggleCutoffUnmetAlbums({
+ albumIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ }
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
+ }
+
+ onSearchAllCutoffUnmetConfirmed = () => {
+ this.props.onSearchAllCutoffUnmetPress();
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ }
+
+ onConfirmSearchAllCutoffUnmetModalClose = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForCutoffUnmetAlbums,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllCutoffUnmetModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ Error fetching cutoff unmet
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No cutoff unmet items
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Are you sure you want to search for all {totalRecords} Cutoff Unmet albums?
+
+
+ This cannot be cancelled once started without restarting Lidarr.
+
+
+ }
+ confirmLabel="Search"
+ onConfirm={this.onSearchAllCutoffUnmetConfirmed}
+ onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
+ />
+
+ }
+
+
+ );
+ }
+}
+
+CutoffUnmet.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForCutoffUnmetAlbums: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleCutoffUnmetAlbums: PropTypes.func.isRequired,
+ onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
+};
+
+export default CutoffUnmet;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
new file mode 100644
index 000000000..f1259b258
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
@@ -0,0 +1,186 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import * as commandNames from 'Commands/commandNames';
+import CutoffUnmet from './CutoffUnmet';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.cutoffUnmet,
+ createCommandExecutingSelector(commandNames.CUTOFF_UNMET_ALBUM_SEARCH),
+ (cutoffUnmet, isSearchingForCutoffUnmetAlbums) => {
+
+ return {
+ isSearchingForCutoffUnmetAlbums,
+ isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
+ ...cutoffUnmet
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails,
+ fetchTrackFiles,
+ clearTrackFiles
+};
+
+class CutoffUnmetConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchCutoffUnmet,
+ gotoCutoffUnmetFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['trackFileUpdated']);
+
+ if (useCurrentPage) {
+ fetchCutoffUnmet();
+ } else {
+ gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'id');
+ const trackFileIds = selectUniqueIds(this.props.items, 'trackFileId');
+
+ this.props.fetchQueueDetails({ albumIds });
+
+ if (trackFileIds.length) {
+ this.props.fetchTrackFiles({ trackFileIds });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCutoffUnmet();
+ this.props.clearQueueDetails();
+ this.props.clearTrackFiles();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchCutoffUnmet();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoCutoffUnmetPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoCutoffUnmetNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoCutoffUnmetLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoCutoffUnmetPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setCutoffUnmetSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setCutoffUnmetFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setCutoffUnmetTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: selected
+ });
+ }
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CutoffUnmetConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchCutoffUnmet: PropTypes.func.isRequired,
+ gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPage: PropTypes.func.isRequired,
+ setCutoffUnmetSort: PropTypes.func.isRequired,
+ setCutoffUnmetFilter: PropTypes.func.isRequired,
+ setCutoffUnmetTableOption: PropTypes.func.isRequired,
+ clearCutoffUnmet: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector)
+);
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
new file mode 100644
index 000000000..106842b2b
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
@@ -0,0 +1,6 @@
+.episode,
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
new file mode 100644
index 000000000..6cf592fa6
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
@@ -0,0 +1,141 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import albumEntities from 'Album/albumEntities';
+import AlbumTitleLink from 'Album/AlbumTitleLink';
+import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
+import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import styles from './CutoffUnmetRow.css';
+
+function CutoffUnmetRow(props) {
+ const {
+ id,
+ trackFileId,
+ artist,
+ releaseDate,
+ foreignAlbumId,
+ albumType,
+ title,
+ disambiguation,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albumTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albumType') {
+ return (
+
+ {albumType}
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+CutoffUnmetRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ trackFileId: PropTypes.number,
+ artist: PropTypes.object.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default CutoffUnmetRow;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js
new file mode 100644
index 000000000..625055c57
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import CutoffUnmetRow from './CutoffUnmetRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CutoffUnmetRow);
diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js
new file mode 100644
index 000000000..56850b67b
--- /dev/null
+++ b/frontend/src/Wanted/Missing/Missing.js
@@ -0,0 +1,299 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import MissingRowConnector from './MissingRowConnector';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class Missing extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllMissingModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ }
+
+ onToggleSelectedPress = () => {
+ const albumIds = this.getSelectedIds();
+
+ this.props.batchToggleMissingAlbums({
+ albumIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ }
+
+ onSearchAllMissingPress = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: true });
+ }
+
+ onSearchAllMissingConfirmed = () => {
+ this.props.onSearchAllMissingPress();
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ }
+
+ onConfirmSearchAllMissingModalClose = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForMissingAlbums,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllMissingModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ Error fetching missing items
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No missing items
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Are you sure you want to search for all {totalRecords} missing albums?
+
+
+ This cannot be cancelled once started without restarting Lidarr.
+
+
+ }
+ confirmLabel="Search"
+ onConfirm={this.onSearchAllMissingConfirmed}
+ onCancel={this.onConfirmSearchAllMissingModalClose}
+ />
+
+ }
+
+
+
+
+ );
+ }
+}
+
+Missing.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForMissingAlbums: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleMissingAlbums: PropTypes.func.isRequired,
+ onSearchAllMissingPress: PropTypes.func.isRequired
+};
+
+export default Missing;
diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js
new file mode 100644
index 000000000..ec90e274d
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingConnector.js
@@ -0,0 +1,174 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import withCurrentPage from 'Components/withCurrentPage';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import * as commandNames from 'Commands/commandNames';
+import Missing from './Missing';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.missing,
+ createCommandExecutingSelector(commandNames.MISSING_ALBUM_SEARCH),
+ (missing, isSearchingForMissingAlbums) => {
+
+ return {
+ isSearchingForMissingAlbums,
+ isSaving: missing.items.filter((m) => m.isSaving).length > 1,
+ ...missing
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class MissingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchMissing,
+ gotoMissingFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['trackFileUpdated']);
+
+ if (useCurrentPage) {
+ fetchMissing();
+ } else {
+ gotoMissingFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'id');
+ this.props.fetchQueueDetails({ albumIds });
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearMissing();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchMissing();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoMissingFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoMissingPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoMissingNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoMissingLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoMissingPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setMissingSort({ sortKey });
+ }
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setMissingFilter({ selectedFilterKey });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setMissingTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoMissingFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: selected
+ });
+ }
+
+ onSearchAllMissingPress = () => {
+ this.props.executeCommand({
+ name: commandNames.MISSING_ALBUM_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MissingConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchMissing: PropTypes.func.isRequired,
+ gotoMissingFirstPage: PropTypes.func.isRequired,
+ gotoMissingPreviousPage: PropTypes.func.isRequired,
+ gotoMissingNextPage: PropTypes.func.isRequired,
+ gotoMissingLastPage: PropTypes.func.isRequired,
+ gotoMissingPage: PropTypes.func.isRequired,
+ setMissingSort: PropTypes.func.isRequired,
+ setMissingFilter: PropTypes.func.isRequired,
+ setMissingTableOption: PropTypes.func.isRequired,
+ clearMissing: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(MissingConnector)
+);
diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css
new file mode 100644
index 000000000..1794c2530
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.css
@@ -0,0 +1,5 @@
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js
new file mode 100644
index 000000000..f019c8aca
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.js
@@ -0,0 +1,122 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import albumEntities from 'Album/albumEntities';
+import AlbumTitleLink from 'Album/AlbumTitleLink';
+import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+
+function MissingRow(props) {
+ const {
+ id,
+ artist,
+ releaseDate,
+ albumType,
+ foreignAlbumId,
+ title,
+ disambiguation,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albumTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albumType') {
+ return (
+
+ {albumType}
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+MissingRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artist: PropTypes.object.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default MissingRow;
diff --git a/frontend/src/Wanted/Missing/MissingRowConnector.js b/frontend/src/Wanted/Missing/MissingRowConnector.js
new file mode 100644
index 000000000..f0a30d9cd
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import MissingRow from './MissingRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MissingRow);
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 000000000..04e0e11ef
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,15 @@
+html,
+body {
+ height: 100%; /* needed for proper layout */
+}
+
+body {
+ overflow: hidden;
+ background-color: #f5f7fa;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ body {
+ overflow-y: auto;
+ }
+}
diff --git a/frontend/src/index.html b/frontend/src/index.html
new file mode 100644
index 000000000..a6970fb2d
--- /dev/null
+++ b/frontend/src/index.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Lidarr
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/index.js b/frontend/src/index.js
new file mode 100644
index 000000000..9f67578ad
--- /dev/null
+++ b/frontend/src/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import { render } from 'react-dom';
+import { createBrowserHistory } from 'history';
+import createAppStore from 'Store/createAppStore';
+import App from './App/App';
+import 'Styles/globals.css';
+import './index.css';
+
+const history = createBrowserHistory();
+const store = createAppStore(history);
+
+render(
+ ,
+ document.getElementById('root')
+);
diff --git a/frontend/src/login.html b/frontend/src/login.html
new file mode 100644
index 000000000..2afba6d92
--- /dev/null
+++ b/frontend/src/login.html
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login - Lidarr
+
+
+
+
+
+
+
+
+
+
+
+
+ SIGN IN TO CONTINUE
+
+
+
+
+
+
+
+ ©
+
+ -
+ Lidarr
+
+
+
+
+
+
+
diff --git a/src/UI/oauth.html b/frontend/src/oauth.html
similarity index 84%
rename from src/UI/oauth.html
rename to frontend/src/oauth.html
index fe6ddf864..16a34dbf3 100644
--- a/src/UI/oauth.html
+++ b/frontend/src/oauth.html
@@ -2,7 +2,7 @@
- oauth landing page
+ OAuth landing page
@@ -10,4 +10,4 @@
Shouldn't see this
-
\ No newline at end of file
+
diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js
new file mode 100644
index 000000000..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..aec17072e
--- /dev/null
+++ b/frontend/src/preload.js
@@ -0,0 +1,2 @@
+/* eslint no-undef: 0 */
+__webpack_public_path__ = `${window.Lidarr.urlBase}/`;
diff --git a/frontend/src/vendor.js b/frontend/src/vendor.js
new file mode 100644
index 000000000..2b08817be
--- /dev/null
+++ b/frontend/src/vendor.js
@@ -0,0 +1,5 @@
+/* Base */
+// require('jquery');
+require('lodash');
+require('moment');
+// require('signalR');
diff --git a/gulp/build.js b/gulp/build.js
deleted file mode 100644
index 23f457baf..000000000
--- a/gulp/build.js
+++ /dev/null
@@ -1,18 +0,0 @@
-var gulp = require('gulp');
-var runSequence = require('run-sequence');
-
-require('./clean');
-require('./less');
-require('./handlebars');
-require('./copy');
-
-gulp.task('build', function() {
- return runSequence('clean', [
- 'webpack',
- 'less',
- 'handlebars',
- 'copyHtml',
- 'copyContent',
- 'copyJs'
- ]);
-});
diff --git a/gulp/clean.js b/gulp/clean.js
deleted file mode 100644
index 58f9c223f..000000000
--- a/gulp/clean.js
+++ /dev/null
@@ -1,8 +0,0 @@
-var gulp = require('gulp');
-var del = require('del');
-
-var paths = require('./paths');
-
-gulp.task('clean', function(cb) {
- del([paths.dest.root], cb);
-});
diff --git a/gulp/copy.js b/gulp/copy.js
deleted file mode 100644
index ab380855d..000000000
--- a/gulp/copy.js
+++ /dev/null
@@ -1,31 +0,0 @@
-var gulp = require('gulp');
-var print = require('gulp-print');
-var cache = require('gulp-cached');
-var livereload = require('gulp-livereload');
-
-var paths = require('./paths.js');
-
-gulp.task('copyJs', function () {
- return gulp.src(
- [
- paths.src.root + 'polyfills.js',
- paths.src.root + 'JsLibraries/handlebars.runtime.js'
- ])
- .pipe(cache('copyJs'))
- .pipe(print())
- .pipe(gulp.dest(paths.dest.root))
- .pipe(livereload());
-});
-
-gulp.task('copyHtml', function () {
- return gulp.src(paths.src.html)
- .pipe(cache('copyHtml'))
- .pipe(gulp.dest(paths.dest.root))
- .pipe(livereload());
-});
-
-gulp.task('copyContent', function () {
- return gulp.src([paths.src.content + '**/*.*', '!**/*.less'])
- .pipe(gulp.dest(paths.dest.content))
- .pipe(livereload());
-});
diff --git a/gulp/errorHandler.js b/gulp/errorHandler.js
deleted file mode 100644
index db24e1a66..000000000
--- a/gulp/errorHandler.js
+++ /dev/null
@@ -1,7 +0,0 @@
-module.exports = {
- onError : function(error) {
- //If you want details of the error in the console
- console.log(error.toString());
- this.emit('end');
- }
-};
\ No newline at end of file
diff --git a/gulp/gulpFile.js b/gulp/gulpFile.js
deleted file mode 100644
index 428fad285..000000000
--- a/gulp/gulpFile.js
+++ /dev/null
@@ -1,11 +0,0 @@
-require('./watch.js');
-require('./build.js');
-require('./clean.js');
-require('./jshint.js');
-require('./handlebars.js');
-require('./copy.js');
-require('./less.js');
-require('./stripBom.js');
-require('./imageMin.js');
-require('./webpack.js');
-require('./start.js');
diff --git a/gulp/handlebars.js b/gulp/handlebars.js
deleted file mode 100644
index aab62f438..000000000
--- a/gulp/handlebars.js
+++ /dev/null
@@ -1,55 +0,0 @@
-var gulp = require('gulp');
-var handlebars = require('gulp-handlebars');
-var declare = require('gulp-declare');
-var concat = require('gulp-concat');
-var wrap = require("gulp-wrap");
-var livereload = require('gulp-livereload');
-var path = require('path');
-var streamqueue = require('streamqueue');
-var stripbom = require('gulp-stripbom');
-
-var paths = require('./paths.js');
-
-gulp.task('handlebars', function() {
-
- var coreStream = gulp.src([
- paths.src.templates,
- '!*/**/*Partial.*'
- ])
- .pipe(stripbom({ showLog : false }))
- .pipe(handlebars())
- .pipe(declare({
- namespace : 'T',
- noRedeclare : true,
- processName : function(filePath) {
-
- filePath = path.relative(paths.src.root, filePath);
-
- return filePath.replace(/\\/g, '/')
- .toLocaleLowerCase()
- .replace('template', '')
- .replace('.js', '');
- }
- }));
-
- var partialStream = gulp.src([paths.src.partials])
- .pipe(stripbom({ showLog : false }))
- .pipe(handlebars())
- .pipe(wrap('Handlebars.template(<%= contents %>)'))
- .pipe(wrap('Handlebars.registerPartial(<%= processPartialName(file.relative) %>, <%= contents %>)', {}, {
- imports : {
- processPartialName : function(fileName) {
- return JSON.stringify(
- path.basename(fileName, '.js')
- );
- }
- }
- }));
-
- return streamqueue({ objectMode : true },
- partialStream,
- coreStream
- ).pipe(concat('templates.js'))
- .pipe(gulp.dest(paths.dest.root))
- .pipe(livereload());
-});
diff --git a/gulp/imageMin.js b/gulp/imageMin.js
deleted file mode 100644
index 6c8236e03..000000000
--- a/gulp/imageMin.js
+++ /dev/null
@@ -1,15 +0,0 @@
-var gulp = require('gulp');
-var print = require('gulp-print');
-var paths = require('./paths.js');
-
-gulp.task('imageMin', function() {
- 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/'));
-});
\ No newline at end of file
diff --git a/gulp/jshint.js b/gulp/jshint.js
deleted file mode 100644
index 650ad02c2..000000000
--- a/gulp/jshint.js
+++ /dev/null
@@ -1,15 +0,0 @@
-var gulp = require('gulp');
-var jshint = require('gulp-jshint');
-var stylish = require('jshint-stylish');
-var cache = require('gulp-cached');
-var paths = require('./paths.js');
-
-gulp.task('jshint', function() {
- return gulp.src([
- paths.src.scripts,
- paths.src.exclude.libs
- ])
- .pipe(cache('jshint'))
- .pipe(jshint())
- .pipe(jshint.reporter(stylish));
-});
diff --git a/gulp/less.js b/gulp/less.js
deleted file mode 100644
index 76e04b8dc..000000000
--- a/gulp/less.js
+++ /dev/null
@@ -1,46 +0,0 @@
-var gulp = require('gulp');
-
-var less = require('gulp-less');
-var postcss = require('gulp-postcss');
-var sourcemaps = require('gulp-sourcemaps');
-var autoprefixer = require('autoprefixer-core');
-var livereload = require('gulp-livereload');
-
-var print = require('gulp-print');
-var paths = require('./paths');
-var errorHandler = require('./errorHandler');
-
-gulp.task('less', function() {
-
- var src = [
- paths.src.content + 'bootstrap.less',
- paths.src.content + 'theme.less',
- paths.src.content + 'overrides.less',
- paths.src.root + 'Series/series.less',
- paths.src.root + 'Activity/activity.less',
- paths.src.root + 'AddSeries/addSeries.less',
- paths.src.root + 'Calendar/calendar.less',
- paths.src.root + 'Cells/cells.less',
- paths.src.root + 'ManualImport/manualimport.less',
- paths.src.root + 'Settings/settings.less',
- paths.src.root + 'System/Logs/logs.less',
- paths.src.root + 'System/Update/update.less',
- paths.src.root + 'System/Info/info.less'
- ];
-
- return gulp.src(src)
- .pipe(print())
- .pipe(sourcemaps.init())
- .pipe(less({
- dumpLineNumbers : 'false',
- compress : true,
- yuicompress : true,
- ieCompat : true,
- strictImports : true
- }))
- .pipe(postcss([ autoprefixer({ browsers: ['last 2 versions'] }) ]))
- .on('error', errorHandler.onError)
- .pipe(sourcemaps.write(paths.dest.content))
- .pipe(gulp.dest(paths.dest.content))
- .pipe(livereload());
-});
diff --git a/gulp/paths.js b/gulp/paths.js
deleted file mode 100644
index e05aa1d2b..000000000
--- a/gulp/paths.js
+++ /dev/null
@@ -1,21 +0,0 @@
-var paths = {
- src : {
- root : './src/UI/',
- templates : './src/UI/**/*.hbs',
- html : './src/UI/*.html',
- partials : './src/UI/**/*Partial.hbs',
- scripts : './src/UI/**/*.js',
- less : ['./src/UI/**/*.less'],
- content : './src/UI/Content/',
- images : './src/UI/Content/Images/**/*',
- exclude : {
- libs : '!./src/UI/JsLibraries/**'
- }
- },
- dest : {
- root : './_output/UI/',
- content : './_output/UI/Content/'
- }
-};
-
-module.exports = paths;
diff --git a/gulp/start.js b/gulp/start.js
deleted file mode 100644
index 5b5f88044..000000000
--- a/gulp/start.js
+++ /dev/null
@@ -1,112 +0,0 @@
-// will download and run sonarr (server) in a non-windows enviroment
-// you can use this if you don't care about the server code and just want to work
-// with the web code.
-
-var http = require('http');
-var gulp = require('gulp');
-var fs = require('fs');
-var targz = require('tar.gz');
-var del = require('del');
-var print = require('gulp-print');
-var spawn = require('child_process').spawn;
-
-function download(url, dest, cb) {
- console.log('Downloading ' + url + ' to ' + dest);
- var file = fs.createWriteStream(dest);
- var request = http.get(url, function (response) {
- response.pipe(file);
- file.on('finish', function () {
- console.log('Download completed');
- file.close(cb);
- });
- });
-}
-
-function getLatest(cb) {
- var branch = 'develop';
- process.argv.forEach(function (val) {
- var branchMatch = /branch=([\S]*)/.exec(val);
- if (branchMatch && branchMatch.length > 1) {
- branch = branchMatch[1];
- }
- });
-
- var url = 'http://services.sonarr.tv/v1/update/' + branch + '?os=osx';
-
- console.log('Checking for latest version:', url);
-
- http.get(url, function (res) {
- var data = '';
-
- res.on('data', function (chunk) {
- data += chunk;
- });
-
- res.on('end', function () {
- var updatePackage = JSON.parse(data).updatePackage;
- console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate);
- cb(updatePackage);
- });
- }).on('error', function (e) {
- console.log('problem with request: ' + e.message);
- });
-}
-
-function extract(source, dest, cb) {
- console.log('extracting download page to ' + dest);
- new targz().extract(source, dest, function (err) {
- if (err) {
- console.log(err);
- }
- console.log('Update package extracted.');
- cb();
- });
-}
-
-gulp.task('getSonarr', function () {
-
- //gulp.src('/Users/kayone/git/Sonarr/_start/2.0.0.3288/NzbDrone/*.*')
- // .pipe(print())
- // .pipe(gulp.dest('./_output
-
- //return;
- try {
- fs.mkdirSync('./_start/');
- } catch (e) {
- if (e.code != 'EEXIST') {
- throw e;
- }
- }
-
- getLatest(function (package) {
- var packagePath = "./_start/" + package.filename;
- var dirName = "./_start/" + package.version;
- download(package.url, packagePath, function () {
- extract(packagePath, dirName, function () {
- // clean old binaries
- console.log('Cleaning old binaries');
- del.sync(['./_output/*', '!./_output/UI/']);
- console.log('copying binaries to target');
- gulp.src(dirName + '/NzbDrone/*.*')
- .pipe(gulp.dest('./_output/'));
- });
- });
- });
-});
-
-gulp.task('startSonarr', function () {
-
- var ls = spawn('mono', ['--debug', './_output/NzbDrone.exe']);
-
- ls.stdout.on('data', function (data) {
- process.stdout.write('' + data);
- });
-
- ls.stderr.on('data', function (data) {
- process.stdout.write('' + data);
- });
-
- ls.on('close', function (code) {
- console.log('child process exited with code ' + code);
- });
-});
diff --git a/gulp/stripBom.js b/gulp/stripBom.js
deleted file mode 100644
index 085e6b753..000000000
--- a/gulp/stripBom.js
+++ /dev/null
@@ -1,21 +0,0 @@
-var gulp = require('gulp');
-var paths = require('./paths.js');
-var stripbom = require('gulp-stripbom');
-
-var stripBom = function (dest) {
- gulp.src([paths.src.scripts, paths.src.exclude.libs])
- .pipe(stripbom({ showLog: false }))
- .pipe(gulp.dest(dest));
-
- gulp.src(paths.src.less)
- .pipe(stripbom({ showLog: false }))
- .pipe(gulp.dest(dest));
-
- gulp.src(paths.src.templates)
- .pipe(stripbom({ showLog: false }))
- .pipe(gulp.dest(dest));
-};
-
-gulp.task('stripBom', function () {
- stripBom(paths.src.root);
-});
diff --git a/gulp/watch.js b/gulp/watch.js
deleted file mode 100644
index f9145a464..000000000
--- a/gulp/watch.js
+++ /dev/null
@@ -1,20 +0,0 @@
-var gulp = require('gulp');
-var livereload = require('gulp-livereload');
-
-var paths = require('./paths.js');
-
-require('./jshint.js');
-require('./handlebars.js');
-require('./less.js');
-require('./copy.js');
-require('./webpack.js');
-
-gulp.task('watch', ['jshint', 'handlebars', 'less', 'copyHtml', 'copyContent', 'copyJs'], function () {
- livereload.listen();
- gulp.start('webpackWatch');
- gulp.watch([paths.src.scripts, paths.src.exclude.libs], ['jshint', 'copyJs']);
- gulp.watch(paths.src.templates, ['handlebars']);
- gulp.watch([paths.src.less, paths.src.exclude.libs], ['less']);
- gulp.watch([paths.src.html], ['copyHtml']);
- gulp.watch([paths.src.content + '**/*.*', '!**/*.less'], ['copyContent']);
-});
\ No newline at end of file
diff --git a/gulp/webpack.js b/gulp/webpack.js
deleted file mode 100644
index 64570593c..000000000
--- a/gulp/webpack.js
+++ /dev/null
@@ -1,13 +0,0 @@
-var gulp = require('gulp');
-var webpackStream = require('webpack-stream');
-var livereload = require('gulp-livereload');
-var webpackConfig = require('../webpack.config');
-
-gulp.task('webpack', function() {
- return gulp.src('main.js').pipe(webpackStream(webpackConfig)).pipe(gulp.dest(''));
-});
-
-gulp.task('webpackWatch', function() {
- webpackConfig.watch = true;
- return gulp.src('main.js').pipe(webpackStream(webpackConfig)).pipe(gulp.dest('')).pipe(livereload());
-});
diff --git a/gulpFile.js b/gulpFile.js
index 28dc9b0f1..73636a918 100644
--- a/gulpFile.js
+++ b/gulpFile.js
@@ -1 +1 @@
-require('./gulp/gulpFile.js');
+require('./frontend/gulp/gulpFile.js');
diff --git a/macOS/Lidarr b/macOS/Lidarr
new file mode 100644
index 000000000..b18dedd25
--- /dev/null
+++ b/macOS/Lidarr
@@ -0,0 +1,62 @@
+#!/bin/sh
+
+#get the bundle's MacOS directory full path
+DIR=$(cd "$(dirname "$0")"; pwd)
+
+#change these values to match your app
+EXE_PATH="$DIR/Lidarr.exe"
+APPNAME="Lidarr"
+
+#set up environment
+if [[ -x '/opt/local/bin/mono' ]]; then
+ # Macports and mono-supplied installer path
+ export PATH="/opt/local/bin:$PATH"
+elif [[ -x '/usr/local/bin/mono' ]]; then
+ # Homebrew-supplied path to mono
+ export PATH="/usr/local/bin:$PATH"
+fi
+
+export DYLD_FALLBACK_LIBRARY_PATH="$DIR"
+
+if [ -e /Library/Frameworks/Mono.framework ]; then
+ MONO_FRAMEWORK_PATH=/Library/Frameworks/Mono.framework/Versions/Current
+ export PATH="$MONO_FRAMEWORK_PATH/bin:$PATH"
+ export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$MONO_FRAMEWORK_PATH/lib"
+fi
+
+if [[ -f '/opt/local/lib/libsqlite3.0.dylib' ]]; then
+ export DYLD_FALLBACK_LIBRARY_PATH="/opt/local/lib:$DYLD_FALLBACK_LIBRARY_PATH"
+fi
+
+export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$HOME/lib:/usr/local/lib:/lib:/usr/lib"
+
+#mono version check
+REQUIRED_MAJOR=4
+REQUIRED_MINOR=6
+
+VERSION_TITLE="Cannot launch $APPNAME"
+VERSION_MSG="$APPNAME requires Mono Runtime Environment(MRE) $REQUIRED_MAJOR.$REQUIRED_MINOR or later."
+DOWNLOAD_URL="http://www.mono-project.com/download/#download-mac"
+
+MONO_VERSION="$(mono --version | grep 'Mono JIT compiler version ' | cut -f5 -d\ )"
+# if [[ -o DEBUG ]]; then osascript -e "display dialog \"MONO_VERSION: $MONO_VERSION\""; fi
+
+
+MONO_VERSION_MAJOR="$(echo $MONO_VERSION | cut -f1 -d.)"
+MONO_VERSION_MINOR="$(echo $MONO_VERSION | cut -f2 -d.)"
+if [ -z "$MONO_VERSION" ] \
+ || [ $MONO_VERSION_MAJOR -lt $REQUIRED_MAJOR ] \
+ || [ $MONO_VERSION_MAJOR -eq $REQUIRED_MAJOR -a $MONO_VERSION_MINOR -lt $REQUIRED_MINOR ]
+then
+ osascript \
+ -e "set question to display dialog \"$VERSION_MSG\" with title \"$VERSION_TITLE\" buttons {\"Cancel\", \"Download...\"} default button 2" \
+ -e "if button returned of question is equal to \"Download...\" then open location \"$DOWNLOAD_URL\""
+ echo "$VERSION_TITLE"
+ echo "$VERSION_MSG"
+ exit 1
+fi
+
+MONO_EXEC="exec mono --debug"
+
+#run app using mono
+$MONO_EXEC "$EXE_PATH"
diff --git a/osx/Sonarr.app/Contents/Info.plist b/macOS/Lidarr.app/Contents/Info.plist
similarity index 83%
rename from osx/Sonarr.app/Contents/Info.plist
rename to macOS/Lidarr.app/Contents/Info.plist
index eeae50f41..6e4706fea 100644
--- a/osx/Sonarr.app/Contents/Info.plist
+++ b/macOS/Lidarr.app/Contents/Info.plist
@@ -11,23 +11,23 @@
CFBundleDevelopmentRegion
English
CFBundleExecutable
- Sonarr
+ Lidarr
CFBundleIconFile
- sonarr.icns
+ lidarr.icns
CFBundleIdentifier
- com.osx.sonarr.tv
+ com.osx.lidarr.audio
CFBundleInfoDictionaryVersion
6.0
CFBundleName
- Sonarr
+ Lidarr
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.0
+ 10.0.0.0
CFBundleSignature
xmmd
CFBundleVersion
- 2.0
+ 10.0.0.0
NSAppleScriptEnabled
YES
diff --git a/macOS/Lidarr.app/Contents/Resources/lidarr.icns b/macOS/Lidarr.app/Contents/Resources/lidarr.icns
new file mode 100644
index 000000000..0d92b5b00
Binary files /dev/null and b/macOS/Lidarr.app/Contents/Resources/lidarr.icns differ
diff --git a/osx/Sonarr b/osx/Sonarr
deleted file mode 100644
index db2a35399..000000000
--- a/osx/Sonarr
+++ /dev/null
@@ -1,58 +0,0 @@
-#!/bin/sh
-
-#get the bundle's MacOS directory full path
-DIR=$(cd "$(dirname "$0")"; pwd)
-
-#change these values to match your app
-EXE_PATH="$DIR/NzbDrone.exe"
-APPNAME="Sonarr"
-
-#set up environment
-if [[ -x '/opt/local/bin/mono' ]]; then
- export PATH="/opt/local/bin:$PATH"
-fi
-
-export DYLD_FALLBACK_LIBRARY_PATH="$DIR"
-
-if [ -e /Library/Frameworks/Mono.framework ]; then
- MONO_FRAMEWORK_PATH=/Library/Frameworks/Mono.framework/Versions/Current
- export PATH="$MONO_FRAMEWORK_PATH/bin:$PATH"
- export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$MONO_FRAMEWORK_PATH/lib"
-fi
-
-if [[ -f '/opt/local/lib/libsqlite3.0.dylib' ]]; then
- export DYLD_FALLBACK_LIBRARY_PATH="/opt/local/lib:$DYLD_FALLBACK_LIBRARY_PATH"
-fi
-
-export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$HOME/lib:/usr/local/lib:/lib:/usr/lib"
-
-#mono version check
-REQUIRED_MAJOR=3
-REQUIRED_MINOR=10
-
-VERSION_TITLE="Cannot launch $APPNAME"
-VERSION_MSG="$APPNAME requires Mono Runtime Environment(MRE) $REQUIRED_MAJOR.$REQUIRED_MINOR or later."
-DOWNLOAD_URL="http://www.mono-project.com/download/#download-mac"
-
-MONO_VERSION="$(mono --version | grep 'Mono JIT compiler version ' | cut -f5 -d\ )"
-# if [[ -o DEBUG ]]; then osascript -e "display dialog \"MONO_VERSION: $MONO_VERSION\""; fi
-
-
-MONO_VERSION_MAJOR="$(echo $MONO_VERSION | cut -f1 -d.)"
-MONO_VERSION_MINOR="$(echo $MONO_VERSION | cut -f2 -d.)"
-if [ -z "$MONO_VERSION" ] \
- || [ $MONO_VERSION_MAJOR -lt $REQUIRED_MAJOR ] \
- || [ $MONO_VERSION_MAJOR -eq $REQUIRED_MAJOR -a $MONO_VERSION_MINOR -lt $REQUIRED_MINOR ]
-then
- osascript \
- -e "set question to display dialog \"$VERSION_MSG\" with title \"$VERSION_TITLE\" buttons {\"Cancel\", \"Download...\"} default button 2" \
- -e "if button returned of question is equal to \"Download...\" then open location \"$DOWNLOAD_URL\""
- echo "$VERSION_TITLE"
- echo "$VERSION_MSG"
- exit 1
-fi
-
-MONO_EXEC="exec mono --debug"
-
-#run app using mono
-$MONO_EXEC "$EXE_PATH"
\ No newline at end of file
diff --git a/osx/Sonarr.app/Contents/Resources/sonarr.icns b/osx/Sonarr.app/Contents/Resources/sonarr.icns
deleted file mode 100644
index 884633ff3..000000000
Binary files a/osx/Sonarr.app/Contents/Resources/sonarr.icns and /dev/null differ
diff --git a/package.json b/package.json
index c3556ed7f..18d25c27b 100644
--- a/package.json
+++ b/package.json
@@ -1,46 +1,138 @@
{
- "name": "Sonarr",
- "version": "2.0.0",
- "description": "Sonarr",
- "main": "main.js",
+ "name": "lidarr",
+ "version": "1.0.0",
+ "description": "Lidarr",
"scripts": {
"build": "gulp build",
- "start": "gulp watch"
+ "start": "gulp watch",
+ "watch": "gulp watch",
+ "clean": "git clean -fXd",
+ "lint": "esprint check",
+ "lint-fix": "eslint --fix frontend/** ",
+ "stylelint-linux": "stylelint $(find frontend -name '*.css') --config frontend/.stylelintrc",
+ "stylelint-windows": "stylelint frontend/**/*.css --config frontend/.stylelintrc"
},
"repository": {
"type": "git",
- "url": "git://github.com/Sonarr/Sonarr.git"
+ "url": "git://github.com/Lidarr/Lidarr.git"
},
- "author": "",
+ "author": "Team Lidarr",
"license": "GPL-3.0",
- "gitHead": "9ff7aa1bf7fe38c4c5bdb92f56c8ad556916ed67",
"readmeFilename": "readme.md",
"dependencies": {
- "autoprefixer-core": "5.2.1",
- "del": "1.2.0",
- "gulp": "3.9.0",
- "gulp-cached": "1.1.0",
- "gulp-concat": "2.6.0",
- "gulp-declare": "0.3.0",
- "gulp-handlebars": "3.0.1",
- "gulp-jshint": "1.11.2",
- "gulp-less": "3.0.3",
- "gulp-livereload": "3.8.0",
- "gulp-postcss": "6.0.0",
- "gulp-print": "1.1.0",
- "gulp-replace": "0.5.3",
- "gulp-run": "1.6.8",
- "gulp-sourcemaps": "1.5.2",
- "gulp-stripbom": "1.0.4",
- "gulp-webpack": "1.5.0",
- "gulp-wrap": "0.11.0",
- "handlebars": "3.0.3",
- "jshint-loader": "0.8.3",
- "jshint-stylish": "2.0.1",
- "run-sequence": "1.1.1",
- "streamqueue": "1.1.0",
- "tar.gz": "0.1.1",
- "webpack": "1.12.0",
- "webpack-stream": "2.1.0"
- }
+ "@babel/core": "7.5.5",
+ "@babel/plugin-proposal-class-properties": "7.5.5",
+ "@babel/plugin-proposal-decorators": "7.4.4",
+ "@babel/plugin-proposal-export-default-from": "7.5.2",
+ "@babel/plugin-proposal-export-namespace-from": "7.5.2",
+ "@babel/plugin-proposal-function-sent": "7.5.0",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "7.4.4",
+ "@babel/plugin-proposal-numeric-separator": "7.2.0",
+ "@babel/plugin-proposal-optional-chaining": "7.2.0",
+ "@babel/plugin-proposal-throw-expressions": "7.2.0",
+ "@babel/plugin-syntax-dynamic-import": "7.2.0",
+ "@babel/preset-env": "7.5.5",
+ "@babel/preset-react": "7.0.0",
+ "@fortawesome/fontawesome-free": "5.10.2",
+ "@fortawesome/fontawesome-svg-core": "1.2.22",
+ "@fortawesome/free-regular-svg-icons": "5.10.2",
+ "@fortawesome/free-solid-svg-icons": "5.10.2",
+ "@fortawesome/react-fontawesome": "0.1.4",
+ "@sentry/browser": "5.6.3",
+ "@sentry/integrations": "5.6.1",
+ "ansi-colors": "4.1.1",
+ "autoprefixer": "9.6.1",
+ "babel-eslint": "10.0.3",
+ "babel-loader": "8.0.6",
+ "babel-plugin-inline-classnames": "2.0.1",
+ "babel-plugin-transform-react-remove-prop-types": "0.4.24",
+ "classnames": "2.2.6",
+ "clipboard": "2.0.4",
+ "connected-react-router": "6.5.2",
+ "core-js": "3",
+ "create-react-class": "15.6.3",
+ "css-loader": "3.2.0",
+ "del": "5.1.0",
+ "element-class": "0.2.2",
+ "eslint": "6.1.0",
+ "eslint-plugin-filenames": "1.3.2",
+ "eslint-plugin-react": "7.14.3",
+ "esprint": "0.5.0",
+ "file-loader": "4.2.0",
+ "filesize": "4.1.2",
+ "fuse.js": "3.4.5",
+ "gulp": "4.0.2",
+ "gulp-cached": "1.1.1",
+ "gulp-concat": "2.6.1",
+ "gulp-livereload": "4.0.1",
+ "gulp-postcss": "8.0.0",
+ "gulp-print": "5.0.2",
+ "gulp-sourcemaps": "2.6.5",
+ "gulp-watch": "5.0.1",
+ "gulp-wrap": "0.15.0",
+ "history": "4.9.0",
+ "jdu": "1.0.0",
+ "jquery": "3.4.1",
+ "loader-utils": "^1.1.0",
+ "lodash": "4.17.15",
+ "mini-css-extract-plugin": "0.8.0",
+ "mobile-detect": "1.4.3",
+ "moment": "2.24.0",
+ "mousetrap": "1.6.3",
+ "normalize.css": "8.0.1",
+ "optimize-css-assets-webpack-plugin": "5.0.3",
+ "postcss-color-function": "4.1.0",
+ "postcss-loader": "3.0.0",
+ "postcss-mixins": "6.2.2",
+ "postcss-nested": "4.1.2",
+ "postcss-simple-vars": "5.0.2",
+ "postcss-url": "8.0.0",
+ "prop-types": "15.7.2",
+ "qs": "6.7.0",
+ "react": "16.8.6",
+ "react-addons-shallow-compare": "15.6.2",
+ "react-async-script": "1.1.1",
+ "react-autosuggest": "9.4.3",
+ "react-custom-scrollbars": "4.2.1",
+ "react-dnd": "9.3.4",
+ "react-dnd-html5-backend": "9.3.4",
+ "react-document-title": "2.0.3",
+ "react-dom": "16.8.6",
+ "react-google-recaptcha": "1.1.0",
+ "react-lazyload": "2.6.2",
+ "react-measure": "1.4.7",
+ "react-popper": "1.3.4",
+ "react-redux": "7.1.1",
+ "react-router-dom": "5.0.1",
+ "react-slider": "0.11.2",
+ "react-text-truncate": "0.15.0",
+ "react-virtualized": "9.21.1",
+ "redux": "4.0.4",
+ "redux-actions": "2.6.5",
+ "redux-batched-actions": "0.4.1",
+ "redux-localstorage": "0.4.1",
+ "redux-thunk": "2.3.0",
+ "require-nocache": "1.0.0",
+ "reselect": "4.0.0",
+ "run-sequence": "2.2.1",
+ "signalr": "2.4.1",
+ "streamqueue": "1.1.2",
+ "style-loader": "0.23.1",
+ "stylelint": "10.1.0",
+ "stylelint-order": "3.0.1",
+ "uglifyjs-webpack-plugin": "2.2.0",
+ "url-loader": "2.1.0",
+ "webpack": "4.39.3",
+ "webpack-stream": "5.2.1"
+ },
+ "devDependencies": {
+ "@sentry/cli": "1.47.1"
+ },
+ "main": "index.js",
+ "browserslist": [
+ ">0.25%",
+ "not ie 11",
+ "not op_mini all",
+ "not chrome < 60"
+ ]
}
diff --git a/setup/build.bat b/setup/build.bat
index 1821e5844..faef79cb4 100644
--- a/setup/build.bat
+++ b/setup/build.bat
@@ -1,3 +1,3 @@
-#SET BUILD_NUMBER=1
-#SET branch=develop
-inno\ISCC.exe nzbdrone.iss
\ No newline at end of file
+REM SET BUILD_NUMBER=1
+REM SET branch=develop
+inno\ISCC.exe lidarr.iss
\ No newline at end of file
diff --git a/setup/inno/Default.isl b/setup/inno/Default.isl
index b417cf916..a2711fecd 100644
--- a/setup/inno/Default.isl
+++ b/setup/inno/Default.isl
@@ -216,7 +216,7 @@ InstallingLabel=Please wait while Setup installs [name] on your computer.
; *** "Setup Completed" wizard page
FinishedHeadingLabel=Completing the [name] Setup Wizard
FinishedLabelNoIcons=Setup has finished installing [name] on your computer.
-FinishedLabel=Setup has finished installing [name] on your computer. The application may be launched by selecting the installed icons.
+FinishedLabel=Setup has finished installing [name] on your computer. The application may be launched by selecting the installed shortcuts.
ClickFinish=Click Finish to exit Setup.
FinishedRestartLabel=To complete the installation of [name], Setup must restart your computer. Would you like to restart now?
FinishedRestartMessage=To complete the installation of [name], Setup must restart your computer.%n%nWould you like to restart now?
@@ -323,9 +323,9 @@ ShutdownBlockReasonUninstallingApp=Uninstalling %1.
[CustomMessages]
NameAndVersion=%1 version %2
-AdditionalIcons=Additional icons:
-CreateDesktopIcon=Create a &desktop icon
-CreateQuickLaunchIcon=Create a &Quick Launch icon
+AdditionalIcons=Additional shortcuts:
+CreateDesktopIcon=Create a &desktop shortcut
+CreateQuickLaunchIcon=Create a &Quick Launch shortcut
ProgramOnTheWeb=%1 on the Web
UninstallProgram=Uninstall %1
LaunchProgram=Launch %1
diff --git a/setup/inno/ISCC.exe b/setup/inno/ISCC.exe
index 8e54535ce..cfe29f31e 100644
Binary files a/setup/inno/ISCC.exe and b/setup/inno/ISCC.exe differ
diff --git a/setup/inno/ISCmplr.dll b/setup/inno/ISCmplr.dll
index 39129a3d2..5b17787c6 100644
Binary files a/setup/inno/ISCmplr.dll and b/setup/inno/ISCmplr.dll differ
diff --git a/setup/inno/ISPP.dll b/setup/inno/ISPP.dll
index 87551fb89..93974a0da 100644
Binary files a/setup/inno/ISPP.dll and b/setup/inno/ISPP.dll differ
diff --git a/setup/inno/Setup.e32 b/setup/inno/Setup.e32
index 09cc13598..9aa46fb57 100644
Binary files a/setup/inno/Setup.e32 and b/setup/inno/Setup.e32 differ
diff --git a/setup/inno/SetupLdr.e32 b/setup/inno/SetupLdr.e32
index 956755065..58fdc1732 100644
Binary files a/setup/inno/SetupLdr.e32 and b/setup/inno/SetupLdr.e32 differ
diff --git a/setup/inno/WizModernImage.bmp b/setup/inno/WizModernImage.bmp
index cf844e093..cb05a0632 100644
Binary files a/setup/inno/WizModernImage.bmp and b/setup/inno/WizModernImage.bmp differ
diff --git a/setup/inno/WizModernSmallImage.bmp b/setup/inno/WizModernSmallImage.bmp
index 1e8e49792..63f421040 100644
Binary files a/setup/inno/WizModernSmallImage.bmp and b/setup/inno/WizModernSmallImage.bmp differ
diff --git a/setup/inno/islzma.dll b/setup/inno/islzma.dll
index 49395ada5..18365df0e 100644
Binary files a/setup/inno/islzma.dll and b/setup/inno/islzma.dll differ
diff --git a/setup/lidarr.iss b/setup/lidarr.iss
new file mode 100644
index 000000000..c5b3a8efd
--- /dev/null
+++ b/setup/lidarr.iss
@@ -0,0 +1,75 @@
+; Script generated by the Inno Setup Script Wizard.
+; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
+
+#define AppName "Lidarr"
+#define AppPublisher "Team Lidarr"
+#define AppURL "https://lidarr.audio/"
+#define ForumsURL "https://forums.lidarr.audio/"
+#define AppExeName "Lidarr.exe"
+#define BaseVersion GetEnv('MAJORVERSION')
+#define BuildNumber GetEnv('MINORVERSION')
+#define BuildVersion GetEnv('LIDARRVERSION')
+#define BranchName GetEnv('BUILD_SOURCEBRANCHNAME')
+
+[Setup]
+; NOTE: The value of AppId uniquely identifies this application.
+; Do not use the same AppId value in installers for other applications.
+; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
+AppId={{56C1065D-3523-4025-B76D-6F73F67F7F93}
+AppName={#AppName}
+AppVersion={#BaseVersion}
+AppPublisher={#AppPublisher}
+AppPublisherURL={#AppURL}
+AppSupportURL={#ForumsURL}
+AppUpdatesURL={#AppURL}
+DefaultDirName={commonappdata}\Lidarr\bin
+DisableDirPage=yes
+DefaultGroupName={#AppName}
+DisableProgramGroupPage=yes
+OutputBaseFilename=Lidarr.{#BranchName}.{#BuildVersion}.windows
+SolidCompression=yes
+AppCopyright=Creative Commons 3.0 License
+AllowUNCPath=False
+UninstallDisplayIcon={app}\Lidarr.exe
+DisableReadyPage=True
+CompressionThreads=2
+Compression=lzma2/normal
+AppContact={#ForumsURL}
+VersionInfoVersion={#BaseVersion}.{#BuildNumber}
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"
+Name: "windowsService"; Description: "Install Windows Service (Starts when the computer starts)"; GroupDescription: "Start automatically"; Flags: exclusive
+Name: "startupShortcut"; Description: "Create shortcut in Startup folder (Starts when you log into Windows)"; GroupDescription: "Start automatically"; Flags: exclusive unchecked
+Name: "none"; Description: "Do not start automatically"; GroupDescription: "Start automatically"; Flags: exclusive unchecked
+
+[Files]
+Source: "..\_output\Lidarr.exe"; DestDir: "{app}"; Flags: ignoreversion
+Source: "..\_output\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
+; NOTE: Don't use "Flags: ignoreversion" on any shared system files
+
+[Icons]
+Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
+Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
+Name: "{userstartup}\{#AppName}"; Filename: "{app}\Lidarr.exe"; WorkingDir: "{app}"; Tasks: startupShortcut
+
+[Run]
+Filename: "{app}\Lidarr.Console.exe"; StatusMsg: "Removing previous Windows Service"; Parameters: "/u"; Flags: runhidden waituntilterminated;
+Filename: "{app}\Lidarr.Console.exe"; Description: "Enable Access from Other Devices"; StatusMsg: "Enabling Remote access"; Parameters: "/registerurl"; Flags: postinstall runascurrentuser runhidden waituntilterminated; Tasks: startupShortcut none;
+Filename: "{app}\Lidarr.Console.exe"; StatusMsg: "Installing Windows Service"; Parameters: "/i"; Flags: runhidden waituntilterminated; Tasks: windowsService
+Filename: "{app}\Lidarr.exe"; Description: "Open Lidarr Web UI"; Flags: postinstall skipifsilent nowait; Tasks: windowsService;
+Filename: "{app}\Lidarr.exe"; Description: "Start Lidarr"; Flags: postinstall skipifsilent nowait; Tasks: startupShortcut none;
+
+[UninstallRun]
+Filename: "{app}\lidarr.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist
+
+[Code]
+function PrepareToInstall(var NeedsRestart: Boolean): String;
+var
+ ResultCode: Integer;
+begin
+ Exec(ExpandConstant('{commonappdata}\Lidarr\bin\Lidarr.Console.exe'), '/u', '', 0, ewWaitUntilTerminated, ResultCode)
+end;
diff --git a/setup/nzbdrone.iss b/setup/nzbdrone.iss
deleted file mode 100644
index e667c0d03..000000000
--- a/setup/nzbdrone.iss
+++ /dev/null
@@ -1,60 +0,0 @@
-; Script generated by the Inno Setup Script Wizard.
-; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
-
-#define AppName "Sonarr"
-#define AppPublisher "Team Sonarr"
-#define AppURL "https://sonarr.tv/"
-#define ForumsURL "https://forums.sonarr.tv/"
-#define AppExeName "NzbDrone.exe"
-#define BuildNumber "2.0"
-#define BuildNumber GetEnv('BUILD_NUMBER')
-#define BranchName GetEnv('branch')
-
-[Setup]
-; NOTE: The value of AppId uniquely identifies this application.
-; Do not use the same AppId value in installers for other applications.
-; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
-AppId={{56C1065D-3523-4025-B76D-6F73F67F7F71}
-AppName={#AppName}
-AppVersion=2.0
-AppPublisher={#AppPublisher}
-AppPublisherURL={#AppURL}
-AppSupportURL={#ForumsURL}
-AppUpdatesURL={#AppURL}
-DefaultDirName={commonappdata}\NzbDrone\bin
-DisableDirPage=yes
-DefaultGroupName={#AppName}
-DisableProgramGroupPage=yes
-OutputBaseFilename=NzbDrone.{#BranchName}.{#BuildNumber}
-SolidCompression=yes
-AppCopyright=Creative Commons 3.0 License
-AllowUNCPath=False
-UninstallDisplayIcon={app}\NzbDrone.exe
-DisableReadyPage=True
-CompressionThreads=2
-Compression=lzma2/normal
-AppContact={#ForumsURL}
-VersionInfoVersion={#BuildNumber}
-
-[Languages]
-Name: "english"; MessagesFile: "compiler:Default.isl"
-
-[Tasks]
-;Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
-Name: "windowsService"; Description: "Install as a Windows Service"
-
-[Files]
-Source: "..\_output\NzbDrone.exe"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\_output\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
-; NOTE: Don't use "Flags: ignoreversion" on any shared system files
-
-[Icons]
-Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
-Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
-
-[Run]
-Filename: "{app}\nzbdrone.console.exe"; Parameters: "/u"; Flags: waituntilterminated;
-Filename: "{app}\nzbdrone.console.exe"; Parameters: "/i"; Flags: waituntilterminated; Tasks: windowsService
-
-[UninstallRun]
-Filename: "{app}\nzbdrone.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
new file mode 100644
index 000000000..b3d5c08be
--- /dev/null
+++ b/src/Directory.Build.props
@@ -0,0 +1,80 @@
+
+
+
+ $(MSBuildThisFileDirectory)..\
+
+
+ Library
+ Test
+ Exe
+ Exe
+ Exe
+ Exe
+ Update
+
+
+ false
+ true
+ true
+ true
+
+
+
+ Release
+
+ $(LidarrRootDir)_temp\obj\$(MSBuildProjectName)\
+ $(LidarrRootDir)_temp\obj\$(MSBuildProjectName)\$(Configuration)\
+ $(LidarrRootDir)_temp\bin\$(Configuration)\$(MSBuildProjectName)\
+
+
+ $(LidarrRootDir)_output\
+ $(LidarrRootDir)_tests\
+ $(LidarrRootDir)_output\Lidarr.Update\
+
+
+ $([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(BaseIntermediateOutputPath)'))
+ $([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))
+ $([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(OutputPath)'))
+
+
+ full
+ true
+
+
+
+
+ true
+ true
+
+
+
+
+ Lidarr
+ lidarr.audio
+ Copyright 2017-$([System.DateTime]::Now.ToString('yyyy')) lidarr.audio (GNU General Public v3)
+
+
+ 10.0.0.*
+ $(Configuration)-dev
+
+ false
+ false
+ false
+
+ False
+
+
+
+
+ <_Parameter1>$(AssemblyConfiguration)
+
+
+
+
+
+ false
+
+
+ $(MSBuildProjectName.Replace('Lidarr','NzbDrone'))
+
+
diff --git a/src/ExternalModules/CurlSharp b/src/ExternalModules/CurlSharp
deleted file mode 160000
index cfdbbbd9c..000000000
--- a/src/ExternalModules/CurlSharp
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit cfdbbbd9c6b9612c2756245049a8234ce87dc576
diff --git a/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc
new file mode 100755
index 000000000..e8b3ae314
Binary files /dev/null and b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc differ
diff --git a/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe
new file mode 100755
index 000000000..94d7c81ec
Binary files /dev/null and b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe differ
diff --git a/src/Libraries/MediaInfo/MediaInfo.dll b/src/Libraries/MediaInfo/MediaInfo.dll
deleted file mode 100644
index dcd8637ea..000000000
Binary files a/src/Libraries/MediaInfo/MediaInfo.dll and /dev/null differ
diff --git a/src/Libraries/MediaInfo/libmediainfo.0.dylib b/src/Libraries/MediaInfo/libmediainfo.0.dylib
deleted file mode 100644
index 091dcaec1..000000000
Binary files a/src/Libraries/MediaInfo/libmediainfo.0.dylib and /dev/null differ
diff --git a/src/Libraries/Sqlite/README.txt b/src/Libraries/Sqlite/README.txt
new file mode 100644
index 000000000..29af7f54e
--- /dev/null
+++ b/src/Libraries/Sqlite/README.txt
@@ -0,0 +1,10 @@
+Windows sqlite3.dll binary from here:
+https://www.sqlite.org/2019/sqlite-dll-win32-x86-3280000.zip
+
+MacOS libsqlite3.0.dylib from azure pipeline here:
+https://dev.azure.com/Lidarr/Lidarr/_build?definitionId=4&_a=summary
+
+System.Data.SQLite netstandard2.0 dll compiled in same pipeline with:
+/p:Configuration=ReleaseManagedOnly /p:UseInteropDll=false /p:UseSqliteStandard=true
+
+Both MacOS and System.Data.SQLite from revision 40e714a of https://github.com/lidarr/SQLite.Build
diff --git a/src/Libraries/Sqlite/System.Data.SQLite.dll b/src/Libraries/Sqlite/System.Data.SQLite.dll
index 1e7145a8d..f27c9cd70 100644
Binary files a/src/Libraries/Sqlite/System.Data.SQLite.dll and b/src/Libraries/Sqlite/System.Data.SQLite.dll differ
diff --git a/src/Libraries/Sqlite/System.Data.SQLite.xml b/src/Libraries/Sqlite/System.Data.SQLite.xml
index 6e533f0a6..1dea1eccf 100644
--- a/src/Libraries/Sqlite/System.Data.SQLite.xml
+++ b/src/Libraries/Sqlite/System.Data.SQLite.xml
@@ -67,709 +67,842 @@
This class implements SQLiteBase completely, and is the guts of the code that interop's SQLite with .NET
-
+
- This internal class provides the foundation of SQLite support. It defines all the abstract members needed to implement
- a SQLite data provider, and inherits from SQLiteConvert which allows for simple translations of string to and from SQLite.
+ This field is used to refer to memory allocated for the
+ SQLITE_DBCONFIG_MAINDBNAME value used with the native
+ "sqlite3_db_config" API. If allocated, the associated
+ memeory will be freed when the underlying connection is
+ closed.
-
+
- This base class provides datatype conversion services for the SQLite provider.
+ The opaque pointer returned to us by the sqlite provider
-
+
- The fallback default database type when one cannot be obtained from an
- existing connection instance.
+ The user-defined functions registered on this connection
-
+
- The format string for DateTime values when using the InvariantCulture or CurrentCulture formats.
+ This is the name of the native library file that contains the
+ "vtshim" extension [wrapper].
-
+
- The fallback default database type name when one cannot be obtained from
- an existing connection instance.
+ This is the flag indicate whether the native library file that
+ contains the "vtshim" extension must be dynamically loaded by
+ this class prior to use.
-
+
- The value for the Unix epoch (e.g. January 1, 1970 at midnight, in UTC).
+ This is the name of the native entry point for the "vtshim"
+ extension [wrapper].
-
+
- The value of the OLE Automation epoch represented as a Julian day.
+ The modules created using this connection.
-
+
- An array of ISO-8601 DateTime formats that we support parsing.
+ Constructs the object used to interact with the SQLite core library
+ using the UTF-8 text encoding.
+
+ The DateTime format to be used when converting string values to a
+ DateTime and binding DateTime parameters.
+
+
+ The to be used when creating DateTime
+ values.
+
+
+ The format string to be used when parsing and formatting DateTime
+ values.
+
+
+ The native handle to be associated with the database connection.
+
+
+ The fully qualified file name associated with .
+
+
+ Non-zero if the newly created object instance will need to dispose
+ of when it is no longer needed.
+
-
+
- The internal default format for UTC DateTime values when converting
- to a string.
+ This method attempts to dispose of all the derived
+ object instances currently associated with the native database connection.
-
+
- The internal default format for local DateTime values when converting
- to a string.
+ Returns the number of times the method has been
+ called.
-
+
- An UTF-8 Encoding instance, so we can convert strings to and from UTF-8
+ This method determines whether or not a
+ with a return code of should
+ be thrown after making a call into the SQLite core library.
+
+ Non-zero if a to be thrown. This method
+ will only return non-zero if the method was called
+ one or more times during a call into the SQLite core library (e.g. when
+ the sqlite3_prepare*() or sqlite3_step() APIs are used).
+
-
+
- The default DateTime format for this instance.
+ Resets the value of the field.
-
+
- The default DateTimeKind for this instance.
+ Attempts to interrupt the query currently executing on the associated
+ native database connection.
-
+
- The default DateTime format string for this instance.
+ This function binds a user-defined function to the connection.
+
+ The object instance containing
+ the metadata for the function to be bound.
+
+
+ The object instance that implements the
+ function to be bound.
+
+
+ The flags associated with the parent connection object.
+
-
+
- Initializes the conversion class
+ This function binds a user-defined function to the connection.
- The default date/time format to use for this instance
- The DateTimeKind to use.
- The DateTime format string to use.
+
+ The object instance containing
+ the metadata for the function to be unbound.
+
+
+ The flags associated with the parent connection object.
+
+ Non-zero if the function was unbound and removed.
-
+
- Converts a string to a UTF-8 encoded byte array sized to include a null-terminating character.
+ Returns non-zero if the underlying native connection handle is owned
+ by this instance.
- The string to convert to UTF-8
- A byte array containing the converted string plus an extra 0 terminating byte at the end of the array.
-
+
- Convert a DateTime to a UTF-8 encoded, zero-terminated byte array.
+ Returns the logical list of functions associated with this connection.
-
- This function is a convenience function, which first calls ToString() on the DateTime, and then calls ToUTF8() with the
- string result.
-
- The DateTime to convert.
- The UTF-8 encoded string, including a 0 terminating byte at the end of the array.
-
+
- Converts a UTF-8 encoded IntPtr of the specified length into a .NET string
+ Attempts to free as much heap memory as possible for the database connection.
- The pointer to the memory where the UTF-8 string is encoded
- The number of bytes to decode
- A string containing the translated character(s)
+ A standard SQLite return code (i.e. zero for success and non-zero for failure).
-
+
- Converts a UTF-8 encoded IntPtr of the specified length into a .NET string
+ Attempts to free N bytes of heap memory by deallocating non-essential memory
+ allocations held by the database library. Memory used to cache database pages
+ to improve performance is an example of non-essential memory. This is a no-op
+ returning zero if the SQLite core library was not compiled with the compile-time
+ option SQLITE_ENABLE_MEMORY_MANAGEMENT. Optionally, attempts to reset and/or
+ compact the Win32 native heap, if applicable.
- The pointer to the memory where the UTF-8 string is encoded
- The number of bytes to decode
- A string containing the translated character(s)
+
+ The requested number of bytes to free.
+
+
+ Non-zero to attempt a heap reset.
+
+
+ Non-zero to attempt heap compaction.
+
+
+ The number of bytes actually freed. This value may be zero.
+
+
+ This value will be non-zero if the heap reset was successful.
+
+
+ The size of the largest committed free block in the heap, in bytes.
+ This value will be zero unless heap compaction is enabled.
+
+
+ A standard SQLite return code (i.e. zero for success and non-zero
+ for failure).
+
-
+
- Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind,
- and DateTimeFormatString specified for the connection when it was opened.
+ Shutdown the SQLite engine so that it can be restarted with different
+ configuration options. We depend on auto initialization to recover.
-
- Acceptable ISO8601 DateTime formats are:
-
- THHmmssK
- THHmmK
- HH:mm:ss.FFFFFFFK
- HH:mm:ssK
- HH:mmK
- yyyy-MM-dd HH:mm:ss.FFFFFFFK
- yyyy-MM-dd HH:mm:ssK
- yyyy-MM-dd HH:mmK
- yyyy-MM-ddTHH:mm:ss.FFFFFFFK
- yyyy-MM-ddTHH:mmK
- yyyy-MM-ddTHH:mm:ssK
- yyyyMMddHHmmssK
- yyyyMMddHHmmK
- yyyyMMddTHHmmssFFFFFFFK
- THHmmss
- THHmm
- HH:mm:ss.FFFFFFF
- HH:mm:ss
- HH:mm
- yyyy-MM-dd HH:mm:ss.FFFFFFF
- yyyy-MM-dd HH:mm:ss
- yyyy-MM-dd HH:mm
- yyyy-MM-ddTHH:mm:ss.FFFFFFF
- yyyy-MM-ddTHH:mm
- yyyy-MM-ddTHH:mm:ss
- yyyyMMddHHmmss
- yyyyMMddHHmm
- yyyyMMddTHHmmssFFFFFFF
- yyyy-MM-dd
- yyyyMMdd
- yy-MM-dd
-
- If the string cannot be matched to one of the above formats -OR-
- the DateTimeFormatString if one was provided, an exception will
- be thrown.
-
- The string containing either a long integer number of 100-nanosecond units since
- System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a
- culture-independent formatted date and time string, a formatted date and time string in the current
- culture, or an ISO8601-format string.
- A DateTime value
+ Returns a standard SQLite result code.
-
+
- Converts a string into a DateTime, using the specified DateTimeFormat,
- DateTimeKind and DateTimeFormatString.
+ Shutdown the SQLite engine so that it can be restarted with different
+ configuration options. We depend on auto initialization to recover.
-
- Acceptable ISO8601 DateTime formats are:
-
- THHmmssK
- THHmmK
- HH:mm:ss.FFFFFFFK
- HH:mm:ssK
- HH:mmK
- yyyy-MM-dd HH:mm:ss.FFFFFFFK
- yyyy-MM-dd HH:mm:ssK
- yyyy-MM-dd HH:mmK
- yyyy-MM-ddTHH:mm:ss.FFFFFFFK
- yyyy-MM-ddTHH:mmK
- yyyy-MM-ddTHH:mm:ssK
- yyyyMMddHHmmssK
- yyyyMMddHHmmK
- yyyyMMddTHHmmssFFFFFFFK
- THHmmss
- THHmm
- HH:mm:ss.FFFFFFF
- HH:mm:ss
- HH:mm
- yyyy-MM-dd HH:mm:ss.FFFFFFF
- yyyy-MM-dd HH:mm:ss
- yyyy-MM-dd HH:mm
- yyyy-MM-ddTHH:mm:ss.FFFFFFF
- yyyy-MM-ddTHH:mm
- yyyy-MM-ddTHH:mm:ss
- yyyyMMddHHmmss
- yyyyMMddHHmm
- yyyyMMddTHHmmssFFFFFFF
- yyyy-MM-dd
- yyyyMMdd
- yy-MM-dd
-
- If the string cannot be matched to one of the above formats -OR-
- the DateTimeFormatString if one was provided, an exception will
- be thrown.
-
- The string containing either a long integer number of 100-nanosecond units since
- System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a
- culture-independent formatted date and time string, a formatted date and time string in the current
- culture, or an ISO8601-format string.
- The SQLiteDateFormats to use.
- The DateTimeKind to use.
- The DateTime format string to use.
- A DateTime value
-
-
-
- Converts a julianday value into a DateTime
-
- The value to convert
- A .NET DateTime
+
+ Non-zero to reset the database and temporary directories to their
+ default values, which should be null for both. This parameter has no
+ effect on non-Windows operating systems.
+
+ Returns a standard SQLite result code.
-
+
- Converts a julianday value into a DateTime
+ Determines if the associated native connection handle is open.
- The value to convert
- The DateTimeKind to use.
- A .NET DateTime
+
+ Non-zero if the associated native connection handle is open.
+
-
+
- Converts the specified number of seconds from the Unix epoch into a
- value.
+ Returns the fully qualified path and file name for the currently open
+ database, if any.
-
- The number of whole seconds since the Unix epoch.
-
-
- Either Utc or Local time.
+
+ The name of the attached database to query.
- The new value.
+ The fully qualified path and file name for the currently open database,
+ if any.
-
+
- Converts the specified number of ticks since the epoch into a
- value.
+ This method attempts to determine if a database connection opened
+ with the specified should be
+ allowed into the connection pool.
-
- The number of whole ticks since the epoch.
-
-
- Either Utc or Local time.
+
+ The that were specified when the
+ connection was opened.
- The new value.
+ Non-zero if the connection should (eventually) be allowed into the
+ connection pool; otherwise, zero.
-
+
- Converts a DateTime struct to a JulianDay double
+ Has the sqlite3_errstr() core library API been checked for yet?
+ If so, is it present?
- The DateTime to convert
- The JulianDay value the Datetime represents
-
-
- Converts a DateTime struct to the whole number of seconds since the
- Unix epoch.
-
- The DateTime to convert
- The whole number of seconds since the Unix epoch
+
+
+ Returns the error message for the specified SQLite return code using
+ the sqlite3_errstr() function, falling back to the internal lookup
+ table if necessary.
+
+ WARNING: Do not remove this method, it is used via reflection.
+
+ The SQLite return code.
+ The error message or null if it cannot be found.
-
+
- Returns the DateTime format string to use for the specified DateTimeKind.
- If is not null, it will be returned verbatim.
+ Has the sqlite3_stmt_readonly() core library API been checked for yet?
+ If so, is it present?
- The DateTimeKind to use.
- The DateTime format string to use.
-
- The DateTime format string to use for the specified DateTimeKind.
-
-
+
- Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind,
- and DateTimeFormatString specified for the connection when it was opened.
+ Returns non-zero if the specified statement is read-only in nature.
- The DateTime value to convert
- Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a
- Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time
- string, a formatted date and time string in the current culture, or an ISO8601-format date/time string.
+ The statement to check.
+ True if the outer query is read-only.
-
+
- Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind,
- and DateTimeFormatString specified for the connection when it was opened.
+ This field is used to keep track of whether or not the
+ "SQLite_ForceLogPrepare" environment variable has been queried. If so,
+ it will only be non-zero if the environment variable was present.
- The DateTime value to convert
- The SQLiteDateFormats to use.
- The DateTimeKind to use.
- The DateTime format string to use.
- Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a
- Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time
- string, a formatted date and time string in the current culture, or an ISO8601-format date/time string.
-
+
- Internal function to convert a UTF-8 encoded IntPtr of the specified length to a DateTime.
+ Determines if all calls to prepare a SQL query will be logged,
+ regardless of the flags for the associated connection.
-
- This is a convenience function, which first calls ToString() on the IntPtr to convert it to a string, then calls
- ToDateTime() on the string to return a DateTime.
-
- A pointer to the UTF-8 encoded string
- The length in bytes of the string
- The parsed DateTime value
+
+ Non-zero to log all calls to prepare a SQL query.
+
-
+
- Smart method of splitting a string. Skips quoted elements, removes the quotes.
+ Determines the file name of the native library containing the native
+ "vtshim" extension -AND- whether it should be dynamically loaded by
+ this class.
-
- This split function works somewhat like the String.Split() function in that it breaks apart a string into
- pieces and returns the pieces as an array. The primary differences are:
-
- Only one character can be provided as a separator character
- Quoted text inside the string is skipped over when searching for the separator, and the quotes are removed.
-
- Thus, if splitting the following string looking for a comma:
- One,Two, "Three, Four", Five
-
- The resulting array would contain
- [0] One
- [1] Two
- [2] Three, Four
- [3] Five
-
- Note that the leading and trailing spaces were removed from each item during the split.
-
- Source string to split apart
- Separator character
- A string array of the split up elements
+
+ This output parameter will be set to non-zero if the returned native
+ library file name should be dynamically loaded prior to attempting
+ the creation of native disposable extension modules.
+
+
+ The file name of the native library containing the native "vtshim"
+ extension -OR- null if it cannot be determined.
+
-
+
- Splits the specified string into multiple strings based on a separator
- and returns the result as an array of strings.
+ Calls the native SQLite core library in order to create a disposable
+ module containing the implementation of a virtual table.
-
- The string to split into pieces based on the separator character. If
- this string is null, null will always be returned. If this string is
- empty, an array of zero strings will always be returned.
+
+ The module object to be used when creating the native disposable module.
-
- The character used to divide the original string into sub-strings.
- This character cannot be a backslash or a double-quote; otherwise, no
- work will be performed and null will be returned.
+
+ The flags for the associated object instance.
-
- If this parameter is non-zero, all double-quote characters will be
- retained in the returned list of strings; otherwise, they will be
- dropped.
+
+
+
+ Calls the native SQLite core library in order to cleanup the resources
+ associated with a module containing the implementation of a virtual table.
+
+
+ The module object previously passed to the
+ method.
-
- Upon failure, this parameter will be modified to contain an appropriate
- error message.
+
+ The flags for the associated object instance.
-
- The new array of strings or null if the input string is null -OR- the
- separator character is a backslash or a double-quote -OR- the string
- contains an unbalanced backslash or double-quote character.
-
-
+
- Queries and returns the string representation for an object, using the
- specified (or current) format provider.
+ Calls the native SQLite core library in order to declare a virtual table
+ in response to a call into the
+ or virtual table methods.
-
- The object instance to return the string representation for.
+
+ The virtual table module that is to be responsible for the virtual table
+ being declared.
-
- The format provider to use -OR- null if the current format provider for
- the thread should be used instead.
+
+ The string containing the SQL statement describing the virtual table to
+ be declared.
+
+
+ Upon success, the contents of this parameter are undefined. Upon failure,
+ it should contain an appropriate error message.
- The string representation for the object instance -OR- null if the
- object instance is also null.
+ A standard SQLite return code.
-
+
- Attempts to convert an arbitrary object to the Boolean data type.
- Null object values are converted to false. Throws an exception
- upon failure.
+ Calls the native SQLite core library in order to declare a virtual table
+ function in response to a call into the
+ or virtual table methods.
-
- The object value to convert.
+
+ The virtual table module that is to be responsible for the virtual table
+ function being declared.
-
- The format provider to use.
+
+ The number of arguments to the function being declared.
-
- If non-zero, a string value will be converted using the
-
- method; otherwise, the
- method will be used.
+
+ The name of the function being declared.
+
+
+ Upon success, the contents of this parameter are undefined. Upon failure,
+ it should contain an appropriate error message.
- The converted boolean value.
+ A standard SQLite return code.
-
-
- Convert a value to true or false.
-
- A string or number representing true or false
-
-
-
-
- Convert a string to true or false.
-
- A string representing true or false
-
-
- "yes", "no", "y", "n", "0", "1", "on", "off" as well as Boolean.FalseString and Boolean.TrueString will all be
- converted to a proper boolean value.
-
-
-
+
- Converts a SQLiteType to a .NET Type object
+ Builds an error message string fragment containing the
+ defined values of the
+ enumeration.
- The SQLiteType to convert
- Returns a .NET Type object
+
+ The built string fragment.
+
-
+
- For a given intrinsic type, return a DbType
+ Builds an error message string fragment containing the
+ defined values of the
+ enumeration.
- The native type to convert
- The corresponding (closest match) DbType
+
+ The built string fragment.
+
-
+
- Returns the ColumnSize for the given DbType
+ Returns the current and/or highwater values for the specified
+ database status parameter.
- The DbType to get the size of
-
+
+ The database status parameter to query.
+
+
+ Non-zero to reset the highwater value to the current value.
+
+
+ If applicable, receives the current value.
+
+
+ If applicable, receives the highwater value.
+
+
+ A standard SQLite return code.
+
-
+
- Determines the default database type name to be used when a
- per-connection value is not available.
+ Change a configuration option value for the database.
+ connection.
-
- The connection context for type mappings, if any.
+
+ The database configuration option to change.
+
+
+ The new value for the specified configuration option.
- The default database type name to use.
+ A standard SQLite return code.
-
+
- If applicable, issues a trace log message warning about falling back to
- the default database type name.
+ Enables or disables extension loading by SQLite.
-
- The database value type.
-
-
- The flags associated with the parent connection object.
-
-
- The textual name of the database type.
+
+ True to enable loading of extensions, false to disable.
-
+
- If applicable, issues a trace log message warning about falling back to
- the default database value type.
+ Loads a SQLite extension library from the named file.
-
- The textual name of the database type.
-
-
- The flags associated with the parent connection object.
+
+ The name of the dynamic link library file containing the extension.
-
- The database value type.
+
+ The name of the exported function used to initialize the extension.
+ If null, the default "sqlite3_extension_init" will be used.
-
-
- For a given database value type, return the "closest-match" textual database type name.
-
- The connection context for custom type mappings, if any.
- The database value type.
- The flags associated with the parent connection object.
- The type name or an empty string if it cannot be determined.
+
+ Enables or disables extended result codes returned by SQLite
-
+
+ Gets the last SQLite error code
+
+
+ Gets the last SQLite extended error code
+
+
+ Add a log message via the SQLite sqlite3_log interface.
+
+
+ Add a log message via the SQLite sqlite3_log interface.
+
+
- Convert a DbType to a Type
+ Allows the setting of a logging callback invoked by SQLite when a
+ log event occurs. Only one callback may be set. If NULL is passed,
+ the logging callback is unregistered.
- The DbType to convert from
- The closest-match .NET type
+ The callback function to invoke.
+ Returns a result code
-
+
- For a given type, return the closest-match SQLite TypeAffinity, which only understands a very limited subset of types.
+ Appends an error message and an appropriate line-ending to a
+ instance. This is useful because the .NET Compact Framework has a slightly different set
+ of supported methods for the class.
- The type to evaluate
- The SQLite type affinity for that type.
+
+ The instance to append to.
+
+
+ The message to append. It will be followed by an appropriate line-ending.
+
-
+
- Builds and returns a map containing the database column types
- recognized by this provider.
+ This method attempts to cause the SQLite native library to invalidate
+ its function pointers that refer to this instance. This is necessary
+ to prevent calls from native code into delegates that may have been
+ garbage collected. Normally, these types of issues can only arise for
+ connections that are added to the pool; howver, it is good practice to
+ unconditionally invalidate function pointers that may refer to objects
+ being disposed.
+
+ Non-zero to also invalidate global function pointers (i.e. those that
+ are not directly associated with this connection on the native side).
+
+
+ Non-zero if this method is being executed within a context where it can
+ throw an exception in the event of failure; otherwise, zero.
+
- A map containing the database column types recognized by this
- provider.
+ Non-zero if this method was successful; otherwise, zero.
-
+
- Determines if a database type is considered to be a string.
+ This method attempts to free the cached database name used with the
+ method.
-
- The database type to check.
+
+ Non-zero if this method is being executed within a context where it can
+ throw an exception in the event of failure; otherwise, zero.
- Non-zero if the database type is considered to be a string, zero
- otherwise.
+ Non-zero if this method was successful; otherwise, zero.
-
+
- Determines and returns the runtime configuration setting string that
- should be used in place of the specified object value.
+ Creates a new SQLite backup object based on the provided destination
+ database connection. The source database connection is the one
+ associated with this object. The source and destination database
+ connections cannot be the same.
-
- The object value to convert to a string.
-
-
- Either the string to use in place of the object value -OR- null if it
- cannot be determined.
-
+ The destination database connection.
+ The destination database name.
+ The source database name.
+ The newly created backup object.
-
+
- Determines the default value to be used when a
- per-connection value is not available.
+ Copies up to N pages from the source database to the destination
+ database associated with the specified backup object.
-
- The connection context for type mappings, if any.
+ The backup object to use.
+
+ The number of pages to copy, negative to copy all remaining pages.
+
+
+ Set to true if the operation needs to be retried due to database
+ locking issues; otherwise, set to false.
- The default value to use.
+ True if there are more pages to be copied, false otherwise.
-
+
- Determines if the specified textual value appears to be a
- value.
+ Returns the number of pages remaining to be copied from the source
+ database to the destination database associated with the specified
+ backup object.
-
- The textual value to inspect.
-
-
- Non-zero if the text looks like a value,
- zero otherwise.
-
+ The backup object to check.
+ The number of pages remaining to be copied.
-
+
- Determines if the specified textual value appears to be an
- value.
+ Returns the total number of pages in the source database associated
+ with the specified backup object.
-
- The textual value to inspect.
-
-
- Non-zero if the text looks like an value,
- zero otherwise.
-
+ The backup object to check.
+ The total number of pages in the source database.
-
+
- Determines if the specified textual value appears to be a
- value.
+ Destroys the backup object, rolling back any backup that may be in
+ progess.
+
+ The backup object to destroy.
+
+
+
+ Determines if the SQLite core library has been initialized for the
+ current process.
-
- The textual value to inspect.
-
- Non-zero if the text looks like a value,
- zero otherwise.
+ A boolean indicating whether or not the SQLite core library has been
+ initialized for the current process.
-
+
- Determines if the specified textual value appears to be a
- value.
+ Determines if the SQLite core library has been initialized for the
+ current process.
-
- The object instance configured with
- the chosen format.
-
-
- The textual value to inspect.
-
- Non-zero if the text looks like a in the
- configured format, zero otherwise.
+ A boolean indicating whether or not the SQLite core library has been
+ initialized for the current process.
-
+
- For a given textual database type name, return the "closest-match" database type.
- This method is called during query result processing; therefore, its performance
- is critical.
+ Helper function to retrieve a column of data from an active statement.
- The connection context for custom type mappings, if any.
- The textual name of the database type to match.
- The flags associated with the parent connection object.
- The .NET DBType the text evaluates to.
+ The statement being step()'d through
+ The flags associated with the connection.
+ The column index to retrieve
+ The type of data contained in the column. If Uninitialized, this function will retrieve the datatype information.
+ Returns the data in the column
-
+
- The error code used for logging exceptions caught in user-provided
- code.
+ Alternate SQLite3 object, overriding many text behaviors to support UTF-16 (Unicode)
-
+
- Sets the status of the memory usage tracking subsystem in the SQLite core library. By default, this is enabled.
- If this is disabled, memory usage tracking will not be performed. This is not really a per-connection value, it is
- global to the process.
+ Constructs the object used to interact with the SQLite core library
+ using the UTF-8 text encoding.
- Non-zero to enable memory usage tracking, zero otherwise.
- A standard SQLite return code (i.e. zero for success and non-zero for failure).
+
+ The DateTime format to be used when converting string values to a
+ DateTime and binding DateTime parameters.
+
+
+ The to be used when creating DateTime
+ values.
+
+
+ The format string to be used when parsing and formatting DateTime
+ values.
+
+
+ The native handle to be associated with the database connection.
+
+
+ The fully qualified file name associated with .
+
+
+ Non-zero if the newly created object instance will need to dispose
+ of when it is no longer needed.
+
-
+
- Attempts to free as much heap memory as possible for the database connection.
+ Overrides SQLiteConvert.ToString() to marshal UTF-16 strings instead of UTF-8
- A standard SQLite return code (i.e. zero for success and non-zero for failure).
+ A pointer to a UTF-16 string
+ The length (IN BYTES) of the string
+ A .NET string
-
+
- Shutdown the SQLite engine so that it can be restarted with different config options.
- We depend on auto initialization to recover.
+ Represents a single SQL backup in SQLite.
-
+
- Determines if the associated native connection handle is open.
+ The underlying SQLite object this backup is bound to.
-
- Non-zero if a database connection is open.
-
-
+
- Opens a database.
+ The actual backup handle.
-
- Implementers should call SQLiteFunction.BindFunctions() and save the array after opening a connection
- to bind all attributed user-defined functions and collating sequences to the new connection.
-
- The filename of the database to open. SQLite automatically creates it if it doesn't exist.
- The flags associated with the parent connection object
- The open flags to use when creating the connection
- The maximum size of the pool for the given filename
- If true, the connection can be pulled from the connection pool
-
+
- Closes the currently-open database.
+ The destination database for the backup.
-
- After the database has been closed implemeters should call SQLiteFunction.UnbindFunctions() to deallocate all interop allocated
- memory associated with the user-defined functions and collating sequences tied to the closed connection.
-
- Non-zero if the operation is allowed to throw exceptions, zero otherwise.
-
+
- Sets the busy timeout on the connection. SQLiteCommand will call this before executing any command.
+ The destination database name for the backup.
- The number of milliseconds to wait before returning SQLITE_BUSY
+
+
+
+ The source database for the backup.
+
+
+
+
+ The source database name for the backup.
+
+
+
+
+ The last result from the StepBackup method of the SQLite3 class.
+ This is used to determine if the call to the FinishBackup method of
+ the SQLite3 class should throw an exception when it receives a non-Ok
+ return code from the core SQLite library.
+
+
+
+
+ Initializes the backup.
+
+ The base SQLite object.
+ The backup handle.
+ The destination database for the backup.
+ The destination database name for the backup.
+ The source database for the backup.
+ The source database name for the backup.
+
+
+
+ Disposes and finalizes the backup.
+
+
+
+
+ This internal class provides the foundation of SQLite support. It defines all the abstract members needed to implement
+ a SQLite data provider, and inherits from SQLiteConvert which allows for simple translations of string to and from SQLite.
+
+
+
+
+ The error code used for logging exceptions caught in user-provided
+ code.
+
+
+
+
+ Returns a string representing the active version of SQLite
+
+
+
+
+ Returns an integer representing the active version of SQLite
+
+
+
+
+ Returns non-zero if this connection to the database is read-only.
+
+
+
+
+ Returns the rowid of the most recent successful INSERT into the database from this connection.
+
+
+
+
+ Returns the number of changes the last executing insert/update caused.
+
+
+
+
+ Returns the amount of memory (in bytes) currently in use by the SQLite core library. This is not really a per-connection
+ value, it is global to the process.
+
+
+
+
+ Returns the maximum amount of memory (in bytes) used by the SQLite core library since the high-water mark was last reset.
+ This is not really a per-connection value, it is global to the process.
+
+
+
+
+ Returns non-zero if the underlying native connection handle is owned by this instance.
+
+
+
+
+ Returns the logical list of functions associated with this connection.
+
+
+
+
+ Sets the status of the memory usage tracking subsystem in the SQLite core library. By default, this is enabled.
+ If this is disabled, memory usage tracking will not be performed. This is not really a per-connection value, it is
+ global to the process.
+
+ Non-zero to enable memory usage tracking, zero otherwise.
+ A standard SQLite return code (i.e. zero for success and non-zero for failure).
+
+
+
+ Attempts to free as much heap memory as possible for the database connection.
+
+ A standard SQLite return code (i.e. zero for success and non-zero for failure).
+
+
+
+ Shutdown the SQLite engine so that it can be restarted with different config options.
+ We depend on auto initialization to recover.
+
+
+
+
+ Determines if the associated native connection handle is open.
+
+
+ Non-zero if a database connection is open.
+
+
+
+
+ Returns the fully qualified path and file name for the currently open
+ database, if any.
+
+
+ The name of the attached database to query.
+
+
+ The fully qualified path and file name for the currently open database,
+ if any.
+
+
+
+
+ Opens a database.
+
+
+ Implementers should call SQLiteFunction.BindFunctions() and save the array after opening a connection
+ to bind all attributed user-defined functions and collating sequences to the new connection.
+
+ The filename of the database to open. SQLite automatically creates it if it doesn't exist.
+ The name of the VFS to use -OR- null to use the default VFS.
+ The flags associated with the parent connection object
+ The open flags to use when creating the connection
+ The maximum size of the pool for the given filename
+ If true, the connection can be pulled from the connection pool
+
+
+
+ Closes the currently-open database.
+
+
+ After the database has been closed implemeters should call SQLiteFunction.UnbindFunctions() to deallocate all interop allocated
+ memory associated with the user-defined functions and collating sequences tied to the closed connection.
+
+ Non-zero if connection is being disposed, zero otherwise.
+
+
+
+ Sets the busy timeout on the connection. SQLiteCommand will call this before executing any command.
+
+ The number of milliseconds to wait before returning SQLITE_BUSY
@@ -820,6 +953,13 @@
The SQLiteStatement to step through
True if a row was returned, False if not.
+
+
+ Returns non-zero if the specified statement is read-only in nature.
+
+ The statement to check.
+ True if the outer query is read-only.
+
Resets a prepared statement so it can be executed again. If the error returned is SQLITE_SCHEMA,
@@ -836,7 +976,7 @@
- This function binds a user-defined functions to the connection.
+ This function binds a user-defined function to the connection.
The object instance containing
@@ -850,6 +990,19 @@
The flags associated with the parent connection object.
+
+
+ This function unbinds a user-defined function from the connection.
+
+
+ The object instance containing
+ the metadata for the function to be unbound.
+
+
+ The flags associated with the parent connection object.
+
+ Non-zero if the function was unbound.
+
Calls the native SQLite core library in order to create a disposable
@@ -859,7 +1012,7 @@
The module object to be used when creating the native disposable module.
- The flags for the associated object instance.
+ The flags for the associated object instance.
@@ -868,18 +1021,18 @@
associated with a module containing the implementation of a virtual table.
- The module object previously passed to the
+ The module object previously passed to the
method.
- The flags for the associated object instance.
+ The flags for the associated object instance.
Calls the native SQLite core library in order to declare a virtual table
- in response to a call into the
- or virtual table methods.
+ in response to a call into the
+ or virtual table methods.
The virtual table module that is to be responsible for the virtual table
@@ -900,8 +1053,8 @@
Calls the native SQLite core library in order to declare a virtual table
- function in response to a call into the
- or virtual table methods.
+ function in response to a call into the
+ or virtual table methods.
The virtual table module that is to be responsible for the virtual table
@@ -921,9 +1074,43 @@
A standard SQLite return code.
+
+
+ Returns the current and/or highwater values for the specified database status parameter.
+
+
+ The database status parameter to query.
+
+
+ Non-zero to reset the highwater value to the current value.
+
+
+ If applicable, receives the current value.
+
+
+ If applicable, receives the highwater value.
+
+
+ A standard SQLite return code.
+
+
+
+
+ Change a configuration option value for the database.
+
+
+ The database configuration option to change.
+
+
+ The new value for the specified configuration option.
+
+
+ A standard SQLite return code.
+
+
- Enables or disabled extension loading by SQLite.
+ Enables or disables extension loading by SQLite.
True to enable loading of extensions, false to disable.
@@ -943,7 +1130,7 @@
- Enables or disabled extened result codes returned by SQLite
+ Enables or disables extened result codes returned by SQLite
true to enable extended result codes, false to disable.
@@ -981,6 +1168,13 @@
zero otherwise.
+
+
+ Returns non-zero if the given database connection is in autocommit mode.
+ Autocommit mode is on by default. Autocommit mode is disabled by a BEGIN
+ statement. Autocommit mode is re-enabled by a COMMIT or ROLLBACK.
+
+
Creates a new SQLite backup object based on the provided destination
@@ -1042,768 +1236,801 @@
The SQLite return code.
The error message or null if it cannot be found.
-
-
- Returns the error message for the specified SQLite return code using
- the sqlite3_errstr() function, falling back to the internal lookup
- table if necessary.
-
- The SQLite return code.
- The error message or null if it cannot be found.
+
+
+
+
-
+
- Returns a string representing the active version of SQLite
+ Creates temporary tables on the connection so schema information can be queried.
+
+ The connection upon which to build the schema tables.
+
-
+
- Returns an integer representing the active version of SQLite
+ The extra behavioral flags that can be applied to a connection.
-
+
- Returns the rowid of the most recent successful INSERT into the database from this connection.
+ No extra flags.
-
+
- Returns the number of changes the last executing insert/update caused.
+ Enable logging of all SQL statements to be prepared.
-
+
- Returns the amount of memory (in bytes) currently in use by the SQLite core library. This is not really a per-connection
- value, it is global to the process.
+ Enable logging of all bound parameter types and raw values.
-
+
- Returns the maximum amount of memory (in bytes) used by the SQLite core library since the high-water mark was last reset.
- This is not really a per-connection value, it is global to the process.
+ Enable logging of all bound parameter strongly typed values.
-
+
- Returns non-zero if the underlying native connection handle is owned by this instance.
+ Enable logging of all exceptions caught from user-provided
+ managed code called from native code via delegates.
-
+
- Returns non-zero if the given database connection is in autocommit mode.
- Autocommit mode is on by default. Autocommit mode is disabled by a BEGIN
- statement. Autocommit mode is re-enabled by a COMMIT or ROLLBACK.
+ Enable logging of backup API errors.
-
+
- The opaque pointer returned to us by the sqlite provider
+ Skip adding the extension functions provided by the native
+ interop assembly.
-
+
- The user-defined functions registered on this connection
+ When binding parameter values with the
+ type, use the interop method that accepts an
+ value.
-
+
- The modules created using this connection.
+ When binding parameter values, always bind them as though they were
+ plain text (i.e. no numeric, date/time, or other conversions should
+ be attempted).
-
+
- Constructs the object used to interact with the SQLite core library
- using the UTF-8 text encoding.
+ When returning column values, always return them as though they were
+ plain text (i.e. no numeric, date/time, or other conversions should
+ be attempted).
-
- The DateTime format to be used when converting string values to a
- DateTime and binding DateTime parameters.
-
-
- The to be used when creating DateTime
- values.
-
-
- The format string to be used when parsing and formatting DateTime
- values.
-
-
- The native handle to be associated with the database connection.
-
-
- The fully qualified file name associated with .
-
-
- Non-zero if the newly created object instance will need to dispose
- of when it is no longer needed.
-
-
+
- This method attempts to dispose of all the derived
- object instances currently associated with the native database connection.
+ Prevent this object instance from
+ loading extensions.
-
+
- Attempts to interrupt the query currently executing on the associated
- native database connection.
+ Prevent this object instance from
+ creating virtual table modules.
-
+
- This function binds a user-defined function to the connection.
+ Skip binding any functions provided by other managed assemblies when
+ opening the connection.
-
- The object instance containing
- the metadata for the function to be bound.
-
-
- The object instance that implements the
- function to be bound.
-
-
- The flags associated with the parent connection object.
-
-
+
- Attempts to free as much heap memory as possible for the database connection.
+ Skip setting the logging related properties of the
+ object instance that was passed to
+ the method.
- A standard SQLite return code (i.e. zero for success and non-zero for failure).
-
+
- Attempts to free N bytes of heap memory by deallocating non-essential memory
- allocations held by the database library. Memory used to cache database pages
- to improve performance is an example of non-essential memory. This is a no-op
- returning zero if the SQLite core library was not compiled with the compile-time
- option SQLITE_ENABLE_MEMORY_MANAGEMENT. Optionally, attempts to reset and/or
- compact the Win32 native heap, if applicable.
+ Enable logging of all virtual table module errors seen by the
+ method.
-
- The requested number of bytes to free.
-
-
- Non-zero to attempt a heap reset.
-
-
- Non-zero to attempt heap compaction.
-
-
- The number of bytes actually freed. This value may be zero.
-
-
- This value will be non-zero if the heap reset was successful.
-
-
- The size of the largest committed free block in the heap, in bytes.
- This value will be zero unless heap compaction is enabled.
-
-
- A standard SQLite return code (i.e. zero for success and non-zero
- for failure).
-
-
+
- Shutdown the SQLite engine so that it can be restarted with different
- configuration options. We depend on auto initialization to recover.
+ Enable logging of certain virtual table module exceptions that cannot
+ be easily discovered via other means.
- Returns a standard SQLite result code.
-
+
- Shutdown the SQLite engine so that it can be restarted with different
- configuration options. We depend on auto initialization to recover.
+ Enable tracing of potentially important [non-fatal] error conditions
+ that cannot be easily reported through other means.
-
- Non-zero to reset the database and temporary directories to their
- default values, which should be null for both. This parameter has no
- effect on non-Windows operating systems.
-
- Returns a standard SQLite result code.
-
+
- Determines if the associated native connection handle is open.
+ When binding parameter values, always use the invariant culture when
+ converting their values from strings.
-
- Non-zero if the associated native connection handle is open.
-
-
+
- Calls the native SQLite core library in order to create a disposable
- module containing the implementation of a virtual table.
+ When binding parameter values, always use the invariant culture when
+ converting their values to strings.
-
- The module object to be used when creating the native disposable module.
-
-
- The flags for the associated object instance.
-
-
+
- Calls the native SQLite core library in order to cleanup the resources
- associated with a module containing the implementation of a virtual table.
+ Disable using the connection pool by default. If the "Pooling"
+ connection string property is specified, its value will override
+ this flag. The precise outcome of combining this flag with the
+ flag is unspecified; however,
+ one of the flags will be in effect.
-
- The module object previously passed to the
- method.
-
-
- The flags for the associated object instance.
-
-
+
- Calls the native SQLite core library in order to declare a virtual table
- in response to a call into the
- or virtual table methods.
+ Enable using the connection pool by default. If the "Pooling"
+ connection string property is specified, its value will override
+ this flag. The precise outcome of combining this flag with the
+ flag is unspecified; however,
+ one of the flags will be in effect.
-
- The virtual table module that is to be responsible for the virtual table
- being declared.
-
-
- The string containing the SQL statement describing the virtual table to
- be declared.
-
-
- Upon success, the contents of this parameter are undefined. Upon failure,
- it should contain an appropriate error message.
-
-
- A standard SQLite return code.
-
-
+
- Calls the native SQLite core library in order to declare a virtual table
- function in response to a call into the
- or virtual table methods.
+ Enable using per-connection mappings between type names and
+ values. Also see the
+ ,
+ , and
+ methods. These
+ per-connection mappings, when present, override the corresponding
+ global mappings.
-
- The virtual table module that is to be responsible for the virtual table
- function being declared.
-
-
- The number of arguments to the function being declared.
-
-
- The name of the function being declared.
-
-
- Upon success, the contents of this parameter are undefined. Upon failure,
- it should contain an appropriate error message.
-
-
- A standard SQLite return code.
-
-
+
- Enables or disabled extension loading by SQLite.
+ Disable using global mappings between type names and
+ values. This may be useful in some very narrow
+ cases; however, if there are no per-connection type mappings, the
+ fallback defaults will be used for both type names and their
+ associated values. Therefore, use of this flag
+ is not recommended.
-
- True to enable loading of extensions, false to disable.
-
-
+
- Loads a SQLite extension library from the named file.
+ When the property is used, it
+ should return non-zero if there were ever any rows in the associated
+ result sets.
-
- The name of the dynamic link library file containing the extension.
-
-
- The name of the exported function used to initialize the extension.
- If null, the default "sqlite3_extension_init" will be used.
-
-
-
- Enables or disabled extended result codes returned by SQLite
-
- Gets the last SQLite error code
-
-
- Gets the last SQLite extended error code
-
-
- Add a log message via the SQLite sqlite3_log interface.
-
-
- Add a log message via the SQLite sqlite3_log interface.
+
+
+ Enable "strict" transaction enlistment semantics. Setting this flag
+ will cause an exception to be thrown if an attempt is made to enlist
+ in a transaction with an unavailable or unsupported isolation level.
+ In the future, more extensive checks may be enabled by this flag as
+ well.
+
-
+
- Allows the setting of a logging callback invoked by SQLite when a
- log event occurs. Only one callback may be set. If NULL is passed,
- the logging callback is unregistered.
+ Enable mapping of unsupported transaction isolation levels to the
+ closest supported transaction isolation level.
- The callback function to invoke.
- Returns a result code
-
+
- Creates a new SQLite backup object based on the provided destination
- database connection. The source database connection is the one
- associated with this object. The source and destination database
- connections cannot be the same.
+ When returning column values, attempt to detect the affinity of
+ textual values by checking if they fully conform to those of the
+ ,
+ ,
+ ,
+ or types.
- The destination database connection.
- The destination database name.
- The source database name.
- The newly created backup object.
-
+
- Copies up to N pages from the source database to the destination
- database associated with the specified backup object.
+ When returning column values, attempt to detect the type of
+ string values by checking if they fully conform to those of
+ the ,
+ ,
+ ,
+ or types.
- The backup object to use.
-
- The number of pages to copy, negative to copy all remaining pages.
-
-
- Set to true if the operation needs to be retried due to database
- locking issues; otherwise, set to false.
-
-
- True if there are more pages to be copied, false otherwise.
-
-
+
- Returns the number of pages remaining to be copied from the source
- database to the destination database associated with the specified
- backup object.
+ Skip querying runtime configuration settings for use by the
+ class, including the default
+ value and default database type name.
+ NOTE: If the
+ and/or
+ properties are not set explicitly nor set via their connection
+ string properties and repeated calls to determine these runtime
+ configuration settings are seen to be a problem, this flag
+ should be set.
- The backup object to check.
- The number of pages remaining to be copied.
-
+
- Returns the total number of pages in the source database associated
- with the specified backup object.
+ When binding parameter values with the
+ type, take their into account as
+ well as that of the associated .
- The backup object to check.
- The total number of pages in the source database.
-
+
- Destroys the backup object, rolling back any backup that may be in
- progess.
+ If an exception is caught when raising the
+ event, the transaction
+ should be rolled back. If this is not specified, the transaction
+ will continue the commit process instead.
- The backup object to destroy.
-
+
- Determines if the SQLite core library has been initialized for the
- current process.
+ If an exception is caught when raising the
+ event, the action should
+ should be denied. If this is not specified, the action will be
+ allowed instead.
-
- A boolean indicating whether or not the SQLite core library has been
- initialized for the current process.
-
-
+
- Determines if the SQLite core library has been initialized for the
- current process.
+ If an exception is caught when raising the
+ event, the operation
+ should be interrupted. If this is not specified, the operation
+ will simply continue.
-
- A boolean indicating whether or not the SQLite core library has been
- initialized for the current process.
-
-
+
- Helper function to retrieve a column of data from an active statement.
+ Attempt to unbind all functions provided by other managed assemblies
+ when closing the connection.
- The statement being step()'d through
- The flags associated with the connection.
- The column index to retrieve
- The type of data contained in the column. If Uninitialized, this function will retrieve the datatype information.
- Returns the data in the column
-
+
- Returns non-zero if the underlying native connection handle is owned
- by this instance.
+ When returning column values as a , skip
+ verifying their affinity.
-
+
- Alternate SQLite3 object, overriding many text behaviors to support UTF-16 (Unicode)
+ Enable using per-connection mappings between type names and
+ values. Also see the
+ ,
+ , and
+ methods.
-
+
- Constructs the object used to interact with the SQLite core library
- using the UTF-8 text encoding.
+ Enable using per-connection mappings between type names and
+ values. Also see the
+ ,
+ , and
+ methods.
-
- The DateTime format to be used when converting string values to a
- DateTime and binding DateTime parameters.
-
-
- The to be used when creating DateTime
- values.
-
-
- The format string to be used when parsing and formatting DateTime
- values.
-
-
- The native handle to be associated with the database connection.
-
-
- The fully qualified file name associated with .
-
-
- Non-zero if the newly created object instance will need to dispose
- of when it is no longer needed.
-
-
+
- Overrides SQLiteConvert.ToString() to marshal UTF-16 strings instead of UTF-8
+ If the database type name has not been explicitly set for the
+ parameter specified, fallback to using the parameter name.
- A pointer to a UTF-16 string
- The length (IN BYTES) of the string
- A .NET string
-
+
- Represents a single SQL backup in SQLite.
+ If the database type name has not been explicitly set for the
+ parameter specified, fallback to using the database type name
+ associated with the value.
-
+
- The underlying SQLite object this backup is bound to.
+ When returning column values, skip verifying their affinity.
-
+
- The actual backup handle.
+ Allow transactions to be nested. The outermost transaction still
+ controls whether or not any changes are ultimately committed or
+ rolled back. All non-outermost transactions are implemented using
+ the SAVEPOINT construct.
-
+
- The destination database for the backup.
+ When binding parameter values, always bind
+ values as though they were plain text (i.e. not ,
+ which is the legacy behavior).
-
+
- The destination database name for the backup.
+ When returning column values, always return
+ values as though they were plain text (i.e. not ,
+ which is the legacy behavior).
-
+
- The source database for the backup.
+ When binding parameter values, always use
+ the invariant culture when converting their values to strings.
-
+
- The source database name for the backup.
+ When returning column values, always use
+ the invariant culture when converting their values from strings.
-
+
- The last result from the StepBackup method of the SQLite3 class.
- This is used to determine if the call to the FinishBackup method of
- the SQLite3 class should throw an exception when it receives a non-Ok
- return code from the core SQLite library.
+ EXPERIMENTAL --
+ Enable waiting for the enlistment to be reset prior to attempting
+ to create a new enlistment. This may be necessary due to the
+ semantics used by distributed transactions, which complete
+ asynchronously.
-
+
- Initializes the backup.
+ When returning column values, always use
+ the invariant culture when converting their values from strings.
- The base SQLite object.
- The backup handle.
- The destination database for the backup.
- The destination database name for the backup.
- The source database for the backup.
- The source database name for the backup.
-
+
- Disposes and finalizes the backup.
+ When returning column values, always use
+ the invariant culture when converting their values from strings.
-
+
-
+ EXPERIMENTAL --
+ Enable strict conformance to the ADO.NET standard, e.g. use of
+ thrown exceptions to indicate common error conditions.
-
+
- Creates temporary tables on the connection so schema information can be queried.
+ EXPERIMENTAL --
+ When opening a connection, attempt to hide the password from the
+ connection string, etc. Given the memory architecture of the CLR,
+ (and P/Invoke) this is not 100% reliable and should not be relied
+ upon for security critical uses or applications.
-
- The connection upon which to build the schema tables.
-
-
+
- The extra behavioral flags that can be applied to a connection.
+ When binding parameter values or returning column values, always
+ treat them as though they were plain text (i.e. no numeric,
+ date/time, or other conversions should be attempted).
-
+
- No extra flags.
+ When binding parameter values, always use the invariant culture when
+ converting their values to strings or from strings.
-
+
- Enable logging of all SQL statements to be prepared.
+ When binding parameter values or returning column values, always
+ treat them as though they were plain text (i.e. no numeric,
+ date/time, or other conversions should be attempted) and always
+ use the invariant culture when converting their values to strings.
-
+
- Enable logging of all bound parameter types and raw values.
+ When binding parameter values or returning column values, always
+ treat them as though they were plain text (i.e. no numeric,
+ date/time, or other conversions should be attempted) and always
+ use the invariant culture when converting their values to strings
+ or from strings.
-
+
- Enable logging of all bound parameter strongly typed values.
+ Enables use of all per-connection value handling callbacks.
-
+
- Enable logging of all exceptions caught from user-provided
- managed code called from native code via delegates.
+ Enables use of all applicable
+ properties as fallbacks for the database type name.
-
+
- Enable logging of backup API errors.
+ Enable all logging.
-
+
- Skip adding the extension functions provided by the native
- interop assembly.
+ The default logging related flags for new connections.
-
+
- When binding parameter values with the
- type, use the interop method that accepts an
- value.
+ The default extra flags for new connections.
-
+
- When binding parameter values, always bind them as though they were
- plain text (i.e. no numeric, date/time, or other conversions should
- be attempted).
+ The default extra flags for new connections with all logging enabled.
-
+
- When returning column values, always return them as though they were
- plain text (i.e. no numeric, date/time, or other conversions should
- be attempted).
+ These are the supported status parameters for use with the native
+ SQLite library.
-
+
- Prevent this object instance from
- loading extensions.
+ This parameter returns the number of lookaside memory slots
+ currently checked out.
-
+
- Prevent this object instance from
- creating virtual table modules.
+ This parameter returns the approximate number of bytes of
+ heap memory used by all pager caches associated with the
+ database connection. The highwater mark associated with
+ SQLITE_DBSTATUS_CACHE_USED is always 0.
-
+
- Skip binding any functions provided by other managed assemblies when
- opening the connection.
+ This parameter returns the approximate number of bytes of
+ heap memory used to store the schema for all databases
+ associated with the connection - main, temp, and any ATTACH-ed
+ databases. The full amount of memory used by the schemas is
+ reported, even if the schema memory is shared with other
+ database connections due to shared cache mode being enabled.
+ The highwater mark associated with SQLITE_DBSTATUS_SCHEMA_USED
+ is always 0.
-
+
- Skip setting the logging related properties of the
- object instance that was passed to
- the method.
+ This parameter returns the number malloc attempts that might
+ have been satisfied using lookaside memory but failed due to
+ all lookaside memory already being in use. Only the high-water
+ value is meaningful; the current value is always zero.
-
+
- Enable logging of all virtual table module errors seen by the
- method.
+ This parameter returns the number malloc attempts that were
+ satisfied using lookaside memory. Only the high-water value
+ is meaningful; the current value is always zero.
-
+
- Enable logging of certain virtual table module exceptions that cannot
- be easily discovered via other means.
+ This parameter returns the number malloc attempts that might
+ have been satisfied using lookaside memory but failed due to
+ the amount of memory requested being larger than the lookaside
+ slot size. Only the high-water value is meaningful; the current
+ value is always zero.
-
+
- Enable tracing of potentially important [non-fatal] error conditions
- that cannot be easily reported through other means.
+ This parameter returns the number malloc attempts that might
+ have been satisfied using lookaside memory but failed due to
+ the amount of memory requested being larger than the lookaside
+ slot size. Only the high-water value is meaningful; the current
+ value is always zero.
-
+
- When binding parameter values, always use the invariant culture when
- converting their values from strings.
+ This parameter returns the number of pager cache hits that
+ have occurred. The highwater mark associated with
+ SQLITE_DBSTATUS_CACHE_HIT is always 0.
-
+
- When binding parameter values, always use the invariant culture when
- converting their values to strings.
+ This parameter returns the number of pager cache misses that
+ have occurred. The highwater mark associated with
+ SQLITE_DBSTATUS_CACHE_MISS is always 0.
-
+
- Disable using the connection pool by default. If the "Pooling"
- connection string property is specified, its value will override
- this flag. The precise outcome of combining this flag with the
- flag is unspecified; however,
- one of the flags will be in effect.
+ This parameter returns the number of dirty cache entries that
+ have been written to disk. Specifically, the number of pages
+ written to the wal file in wal mode databases, or the number
+ of pages written to the database file in rollback mode
+ databases. Any pages written as part of transaction rollback
+ or database recovery operations are not included. If an IO or
+ other error occurs while writing a page to disk, the effect
+ on subsequent SQLITE_DBSTATUS_CACHE_WRITE requests is
+ undefined. The highwater mark associated with
+ SQLITE_DBSTATUS_CACHE_WRITE is always 0.
-
+
- Enable using the connection pool by default. If the "Pooling"
- connection string property is specified, its value will override
- this flag. The precise outcome of combining this flag with the
- flag is unspecified; however,
- one of the flags will be in effect.
+ This parameter returns zero for the current value if and only
+ if all foreign key constraints (deferred or immediate) have
+ been resolved. The highwater mark is always 0.
-
+
- Enable using per-connection mappings between type names and
- values. Also see the
- ,
- , and
- methods. These
- per-connection mappings, when present, override the corresponding
- global mappings.
+ This parameter is similar to DBSTATUS_CACHE_USED, except that
+ if a pager cache is shared between two or more connections the
+ bytes of heap memory used by that pager cache is divided evenly
+ between the attached connections. In other words, if none of
+ the pager caches associated with the database connection are
+ shared, this request returns the same value as DBSTATUS_CACHE_USED.
+ Or, if one or more or the pager caches are shared, the value
+ returned by this call will be smaller than that returned by
+ DBSTATUS_CACHE_USED. The highwater mark associated with
+ SQLITE_DBSTATUS_CACHE_USED_SHARED is always 0.
-
+
- Disable using global mappings between type names and
- values. This may be useful in some very narrow
- cases; however, if there are no per-connection type mappings, the
- fallback defaults will be used for both type names and their
- associated values. Therefore, use of this flag
- is not recommended.
+ These are the supported configuration verbs for use with the native
+ SQLite library. They are used with the
+ method.
-
+
- When the property is used, it
- should return non-zero if there were ever any rows in the associated
- result sets.
+ This value represents an unknown (or invalid) option, do not use it.
-
+
- Enable "strict" transaction enlistment semantics. Setting this flag
- will cause an exception to be thrown if an attempt is made to enlist
- in a transaction with an unavailable or unsupported isolation level.
- In the future, more extensive checks may be enabled by this flag as
- well.
+ This option is used to change the name of the "main" database
+ schema. The sole argument is a pointer to a constant UTF8 string
+ which will become the new schema name in place of "main".
-
+
- Enable mapping of unsupported transaction isolation levels to the
- closest supported transaction isolation level.
+ This option is used to configure the lookaside memory allocator.
+ The value must be an array with three elements. The second element
+ must be an containing the size of each buffer
+ slot. The third element must be an containing
+ the number of slots. The first element must be an
+ that points to a native memory buffer of bytes equal to or greater
+ than the product of the second and third element values.
-
+
- When returning column values, attempt to detect the affinity of
- textual values by checking if they fully conform to those of the
- ,
- ,
- ,
- or types.
+ This option is used to enable or disable the enforcement of
+ foreign key constraints.
-
+
- When returning column values, attempt to detect the type of
- string values by checking if they fully conform to those of
- the ,
- ,
- ,
- or types.
+ This option is used to enable or disable triggers.
-
+
- Skip querying runtime configuration settings for use by the
- class, including the default
- value and default database type name.
- NOTE: If the
- and/or
- properties are not set explicitly nor set via their connection
- string properties and repeated calls to determine these runtime
- configuration settings are seen to be a problem, this flag
- should be set.
+ This option is used to enable or disable the two-argument version
+ of the fts3_tokenizer() function which is part of the FTS3 full-text
+ search engine extension.
-
+
- When binding parameter values or returning column values, always
- treat them as though they were plain text (i.e. no numeric,
- date/time, or other conversions should be attempted).
+ This option is used to enable or disable the loading of extensions.
-
+
- When binding parameter values, always use the invariant culture when
- converting their values to strings or from strings.
+ This option is used to enable or disable the automatic checkpointing
+ when a WAL database is closed.
-
+
- When binding parameter values or returning column values, always
- treat them as though they were plain text (i.e. no numeric,
- date/time, or other conversions should be attempted) and always
- use the invariant culture when converting their values to strings.
+ This option is used to enable or disable the query planner stability
+ guarantee (QPSG).
-
+
- When binding parameter values or returning column values, always
- treat them as though they were plain text (i.e. no numeric,
- date/time, or other conversions should be attempted) and always
- use the invariant culture when converting their values to strings
- or from strings.
+ This option is used to enable or disable the extra EXPLAIN QUERY PLAN
+ output for trigger programs.
-
+
- Enable all logging.
+ This option is used as part of the process to reset a database back
+ to an empty state. Because resetting a database is destructive and
+ irreversible, the process requires the use of this obscure flag and
+ multiple steps to help ensure that it does not happen by accident.
-
+
- The default extra flags for new connections.
+ These constants are used with the sqlite3_trace_v2() API and the
+ callbacks registered by it.
-
+
- The default extra flags for new connections with all logging enabled.
+ Represents a single SQL blob in SQLite.
+
+
+
+
+ The underlying SQLite object this blob is bound to.
+
+
+
+
+ The actual blob handle.
+
+
+
+
+ Initializes the blob.
+
+ The base SQLite object.
+ The blob handle.
+
+
+
+ Creates a object. This will not work
+ for tables that were created WITHOUT ROWID -OR- if the query
+ does not include the "rowid" column or one of its aliases -OR-
+ if the was not created with the
+ flag.
+
+
+ The instance with a result set
+ containing the desired blob column.
+
+
+ The index of the blob column.
+
+
+ Non-zero to open the blob object for read-only access.
+
+
+ The newly created instance -OR- null
+ if an error occurs.
+
+
+
+
+ Creates a object. This will not work
+ for tables that were created WITHOUT ROWID.
+
+
+ The connection to use when opening the blob object.
+
+
+ The name of the database containing the blob object.
+
+
+ The name of the table containing the blob object.
+
+
+ The name of the column containing the blob object.
+
+
+ The integer identifier for the row associated with the desired
+ blob object.
+
+
+ Non-zero to open the blob object for read-only access.
+
+
+ The newly created instance -OR- null
+ if an error occurs.
+
+
+
+
+ Throws an exception if the blob object does not appear to be open.
+
+
+
+
+ Throws an exception if an invalid read/write parameter is detected.
+
+
+ When reading, this array will be populated with the bytes read from
+ the underlying database blob. When writing, this array contains new
+ values for the specified portion of the underlying database blob.
+
+
+ The number of bytes to read or write.
+
+
+ The byte offset, relative to the start of the underlying database
+ blob, where the read or write operation will begin.
+
+
+
+
+ Retargets this object to an underlying database blob for a
+ different row; the database, table, and column remain exactly
+ the same. If this operation fails for any reason, this blob
+ object is automatically disposed.
+
+
+ The integer identifier for the new row.
+
+
+
+
+ Queries the total number of bytes for the underlying database blob.
+
+
+ The total number of bytes for the underlying database blob.
+
+
+
+
+ Reads data from the underlying database blob.
+
+
+ This array will be populated with the bytes read from the
+ underlying database blob.
+
+
+ The number of bytes to read.
+
+
+ The byte offset, relative to the start of the underlying
+ database blob, where the read operation will begin.
+
+
+
+
+ Writes data into the underlying database blob.
+
+
+ This array contains the new values for the specified portion of
+ the underlying database blob.
+
+
+ The number of bytes to write.
+
+
+ The byte offset, relative to the start of the underlying
+ database blob, where the write operation will begin.
+
+
+
+
+ Closes the blob, freeing the associated resources.
+
+
+
+
+ Disposes and finalizes the blob.
+
+
+
+
+ The destructor.
@@ -1815,8 +2042,8 @@
The default connection string to be used when creating a temporary
connection to execute a command via the static
- or
-
+ or
+
methods.
@@ -1945,6 +2172,21 @@
Not implemented
+
+
+ The SQL command text associated with the command
+
+
+
+
+ The amount of time to wait for the connection to become available before erroring out
+
+
+
+
+ The type of the command. SQLite only supports CommandType.Text
+
+
Forwards to the local CreateParameter() function
@@ -1957,6 +2199,44 @@
+
+
+ The connection associated with this command
+
+
+
+
+ Forwards to the local Connection property
+
+
+
+
+ Returns the SQLiteParameterCollection for the given command
+
+
+
+
+ Forwards to the local Parameters property
+
+
+
+
+ The transaction associated with this command. SQLite only supports one transaction per connection, so this property forwards to the
+ command's underlying connection.
+
+
+
+
+ Forwards to the local Transaction property
+
+
+
+
+ Verifies that all SQL queries associated with the current command text
+ can be successfully compiled. A will be
+ raised if any errors occur.
+
+
This function ensures there are no active readers, that we have a valid connection,
@@ -2002,9 +2282,9 @@
This method creates a new connection, executes the query using the given
- execution type and command behavior, closes the connection, and returns
- the results. If the connection string is null, a temporary in-memory
- database connection will be used.
+ execution type and command behavior, closes the connection unless a data
+ reader is created, and returns the results. If the connection string is
+ null, a temporary in-memory database connection will be used.
The text of the command to be executed.
@@ -2043,7 +2323,7 @@
A SQLiteDataReader
-
+
Called by the SQLiteDataReader when the data reader is closed.
@@ -2076,61 +2356,30 @@
The flags to be associated with the reader.
The first column of the first row of the first resultset from the query.
-
-
- Does nothing. Commands are prepared as they are executed the first time, and kept in prepared state afterwards.
-
-
-
-
- Clones a command, including all its parameters
-
- A new SQLiteCommand with the same commandtext, connection and parameters
-
-
-
- The SQL command text associated with the command
-
-
-
-
- The amount of time to wait for the connection to become available before erroring out
-
-
-
-
- The type of the command. SQLite only supports CommandType.Text
-
-
-
-
- The connection associated with this command
-
-
-
-
- Forwards to the local Connection property
-
-
-
-
- Returns the SQLiteParameterCollection for the given command
-
-
-
+
- Forwards to the local Parameters property
+ This method resets all the prepared statements held by this instance
+ back to their initial states, ready to be re-executed.
-
+
- The transaction associated with this command. SQLite only supports one transaction per connection, so this property forwards to the
- command's underlying connection.
+ This method resets all the prepared statements held by this instance
+ back to their initial states, ready to be re-executed.
+
+ Non-zero if the parameter bindings should be cleared as well.
+
+
+ If this is zero, a may be thrown for
+ any unsuccessful return codes from the native library; otherwise, a
+ will only be thrown if the connection
+ or its state is invalid.
+
-
+
- Forwards to the local Transaction property
+ Does nothing. Commands are prepared as they are executed the first time, and kept in prepared state afterwards.
@@ -2143,6 +2392,12 @@
Determines if the command is visible at design time. Defaults to True.
+
+
+ Clones a command, including all its parameters
+
+ A new SQLiteCommand with the same commandtext, connection and parameters
+
SQLite implementation of DbCommandBuilder.
@@ -2159,6 +2414,14 @@
+
+
+ Cleans up resources (native and managed) associated with the current instance.
+
+
+ Zero when being disposed via garbage collection; otherwise, non-zero.
+
+
Minimal amount of parameter processing. Primarily sets the DbType for the parameter equal to the provider type in the schema
@@ -2196,6 +2459,11 @@
A data adapter to receive events on.
+
+
+ Gets/sets the DataAdapter for this CommandBuilder
+
+
Returns the automatically-generated SQLite command to delete rows from the database
@@ -2235,6 +2503,26 @@
+
+
+ Overridden to hide its property from the designer
+
+
+
+
+ Overridden to hide its property from the designer
+
+
+
+
+ Overridden to hide its property from the designer
+
+
+
+
+ Overridden to hide its property from the designer
+
+
Places brackets around an identifier
@@ -2249,6 +2537,11 @@
The quoted (bracketed) identifier
The undecorated identifier
+
+
+ Overridden to hide its property from the designer
+
+
Override helper, which can help the base command builder choose the right keys for the given query
@@ -2256,7519 +2549,14454 @@
-
-
- Gets/sets the DataAdapter for this CommandBuilder
+
+
+ This class represents a single value to be returned
+ from the class via
+ its ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ , or
+ method. If the value of the
+ associated public field of this class is null upon returning from the
+ callback, the null value will only be used if the return type for the
+ method called is not a value type.
+ If the value to be returned from the
+ method is unsuitable (e.g. null with a value type), an exception will
+ be thrown.
-
+
- Overridden to hide its property from the designer
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- Overridden to hide its property from the designer
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- Overridden to hide its property from the designer
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- Overridden to hide its property from the designer
+ The value to be returned from the
+ method.
-
+
- Overridden to hide its property from the designer
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- Event data for connection event handlers.
+ The value to be returned from the
+ method.
-
+
- The type of event being raised.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- The associated with this event, if any.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- The transaction associated with this event, if any.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- The command associated with this event, if any.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- The data reader associated with this event, if any.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- The critical handle associated with this event, if any.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- Command or message text associated with this event, if any.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- Extra data associated with this event, if any.
+ The value to be returned from the
+ method -OR- null to
+ indicate an error.
-
+
- Constructs the object.
+ The value to be returned from the
+ method.
- The type of event being raised.
- The base associated
- with this event, if any.
- The transaction associated with this event, if any.
- The command associated with this event, if any.
- The data reader associated with this event, if any.
- The critical handle associated with this event, if any.
- The command or message text, if any.
- The extra data, if any.
-
+
- Raised when an event pertaining to a connection occurs.
+ The value to be returned from the
+ method.
- The connection involved.
- Extra information about the event.
-
+
- SQLite implentation of DbConnection.
+ This class represents the parameters that are provided
+ to the methods, with
+ the exception of the column index (provided separately).
-
- The property can contain the following parameter(s), delimited with a semi-colon:
-
-
- Parameter
- Values
- Required
- Default
-
- -
-
Data Source
-
- This may be a file name, the string ":memory:", or any supported URI (starting with SQLite 3.7.7).
- Starting with release 1.0.86.0, in order to use more than one consecutive backslash (e.g. for a
- UNC path), each of the adjoining backslash characters must be doubled (e.g. "\\Network\Share\test.db"
- would become "\\\\Network\Share\test.db").
-
- Y
-
-
- -
-
Version
- 3
- N
- 3
-
- -
-
UseUTF16Encoding
- True False
- N
- False
-
- -
-
DateTimeFormat
-
- Ticks - Use the value of DateTime.Ticks.
- ISO8601 - Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC
- DateTime values and "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values).
- JulianDay - The interval of time in days and fractions of a day since January 1, 4713 BC.
- UnixEpoch - The whole number of seconds since the Unix epoch (January 1, 1970).
- InvariantCulture - Any culture-independent string value that the .NET Framework can interpret as a valid DateTime.
- CurrentCulture - Any string value that the .NET Framework can interpret as a valid DateTime using the current culture.
- N
- ISO8601
-
- -
-
DateTimeKind
- Unspecified - Not specified as either UTC or local time.Utc - The time represented is UTC.Local - The time represented is local time.
- N
- Unspecified
-
- -
-
DateTimeFormatString
- The exact DateTime format string to use for all formatting and parsing of all DateTime
- values for this connection.
- N
- null
-
- -
-
BaseSchemaName
- Some base data classes in the framework (e.g. those that build SQL queries dynamically)
- assume that an ADO.NET provider cannot support an alternate catalog (i.e. database) without supporting
- alternate schemas as well; however, SQLite does not fit into this model. Therefore, this value is used
- as a placeholder and removed prior to preparing any SQL statements that may contain it.
- N
- sqlite_default_schema
-
- -
-
BinaryGUID
- True - Store GUID columns in binary formFalse - Store GUID columns as text
- N
- True
-
- -
-
Cache Size
- {size in bytes}
- N
- 2000
-
- -
-
Synchronous
- Normal - Normal file flushing behaviorFull - Full flushing after all writesOff - Underlying OS flushes I/O's
- N
- Full
-
- -
-
Page Size
- {size in bytes}
- N
- 1024
-
- -
-
Password
- {password} - Using this parameter requires that the CryptoAPI based codec be enabled at compile-time for both the native interop assembly and the core managed assemblies; otherwise, using this parameter may result in an exception being thrown when attempting to open the connection.
- N
-
-
- -
-
HexPassword
- {hexPassword} - Must contain a sequence of zero or more hexadecimal encoded byte values without a leading "0x" prefix. Using this parameter requires that the CryptoAPI based codec be enabled at compile-time for both the native interop assembly and the core managed assemblies; otherwise, using this parameter may result in an exception being thrown when attempting to open the connection.
- N
-
-
- -
-
Enlist
- Y - Automatically enlist in distributed transactionsN - No automatic enlistment
- N
- Y
-
- -
-
Pooling
-
- True - Use connection pooling.
- False - Do not use connection pooling.
- WARNING: When using the default connection pool implementation,
- setting this property to True should be avoided by applications that make
- use of COM (either directly or indirectly) due to possible deadlocks that
- can occur during the finalization of some COM objects.
-
- N
- False
-
- -
-
FailIfMissing
- True - Don't create the database if it does not exist, throw an error insteadFalse - Automatically create the database if it does not exist
- N
- False
-
- -
-
Max Page Count
- {size in pages} - Limits the maximum number of pages (limits the size) of the database
- N
- 0
-
- -
-
Legacy Format
- True - Use the more compatible legacy 3.x database formatFalse - Use the newer 3.3x database format which compresses numbers more effectively
- N
- False
-
- -
-
Default Timeout
- {time in seconds} The default command timeout
- N
- 30
-
- -
-
Journal Mode
- Delete - Delete the journal file after a commitPersist - Zero out and leave the journal file on disk after a commitOff - Disable the rollback journal entirely
- N
- Delete
-
- -
-
Read Only
- True - Open the database for read only accessFalse - Open the database for normal read/write access
- N
- False
-
- -
-
Max Pool Size
- The maximum number of connections for the given connection string that can be in the connection pool
- N
- 100
-
- -
-
Default IsolationLevel
- The default transaciton isolation level
- N
- Serializable
-
- -
-
Foreign Keys
- Enable foreign key constraints
- N
- False
-
- -
-
Flags
- Extra behavioral flags for the connection. See the enumeration for possible values.
- N
- Default
-
- -
-
SetDefaults
-
- True - Apply the default connection settings to the opened database.
- False - Skip applying the default connection settings to the opened database.
-
- N
- True
-
- -
-
ToFullPath
-
- True - Attempt to expand the data source file name to a fully qualified path before opening.
- False - Skip attempting to expand the data source file name to a fully qualified path before opening.
-
- N
- True
-
-
-
-
+
- The "invalid value" for the enumeration used
- by the property. This constant is shared
- by this class and the SQLiteConnectionStringBuilder class.
+ This class represents the parameters that are provided to
+ the method, with
+ the exception of the column index (provided separately).
-
+
- The default "stub" (i.e. placeholder) base schema name to use when
- returning column schema information. Used as the initial value of
- the BaseSchemaName property. This should start with "sqlite_*"
- because those names are reserved for use by SQLite (i.e. they cannot
- be confused with the names of user objects).
+ Provides the underlying storage for the
+ property.
-
+
- The managed assembly containing this type.
+ Constructs an instance of this class to pass into a user-defined
+ callback associated with the
+ method.
+
+ The value that was originally specified for the "readOnly"
+ parameter to the method.
+
-
+
- Object used to synchronize access to the static instance data
- for this class.
+ The value that was originally specified for the "readOnly"
+ parameter to the method.
-
+
- The extra connection flags to be used for all opened connections.
+ This class represents the parameters that are provided
+ to the and
+ methods, with
+ the exception of the column index (provided separately).
-
+
- Used to hold the active library version number of SQLite.
+ Provides the underlying storage for the
+ property.
-
+
- State of the current connection
+ Provides the underlying storage for the
+ property.
-
+
- The connection string
+ Provides the underlying storage for the
+ property.
-
+
- Nesting level of the transactions open on the connection
+ Provides the underlying storage for the
+ property.
-
+
- If set, then the connection is currently being disposed.
+ Provides the underlying storage for the
+ property.
-
+
- The default isolation level for new transactions
+ Constructs an instance of this class to pass into a user-defined
+ callback associated with the
+ method.
+
+ The value that was originally specified for the "dataOffset"
+ parameter to the or
+ methods.
+
+
+ The value that was originally specified for the "buffer"
+ parameter to the
+ method.
+
+
+ The value that was originally specified for the "bufferOffset"
+ parameter to the or
+ methods.
+
+
+ The value that was originally specified for the "length"
+ parameter to the or
+ methods.
+
-
+
- Whether or not the connection is enlisted in a distrubuted transaction
+ Constructs an instance of this class to pass into a user-defined
+ callback associated with the
+ method.
+
+ The value that was originally specified for the "dataOffset"
+ parameter to the or
+ methods.
+
+
+ The value that was originally specified for the "buffer"
+ parameter to the
+ method.
+
+
+ The value that was originally specified for the "bufferOffset"
+ parameter to the or
+ methods.
+
+
+ The value that was originally specified for the "length"
+ parameter to the or
+ methods.
+
-
+
- The per-connection mappings between type names and
- values. These mappings override the corresponding global mappings.
+ The value that was originally specified for the "dataOffset"
+ parameter to the or
+ methods.
-
+
- The base SQLite object to interop with
+ The value that was originally specified for the "buffer"
+ parameter to the
+ method.
-
+
- The database filename minus path and extension
+ The value that was originally specified for the "buffer"
+ parameter to the
+ method.
-
+
- Temporary password storage, emptied after the database has been opened
+ The value that was originally specified for the "bufferOffset"
+ parameter to the or
+ methods.
-
+
- The "stub" (i.e. placeholder) base schema name to use when returning
- column schema information.
+ The value that was originally specified for the "length"
+ parameter to the or
+ methods.
-
+
- The extra behavioral flags for this connection, if any. See the
- enumeration for a list of
- possible values.
+ This class represents the parameters and return values for the
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ , and
+ methods.
-
+
- The cached values for all settings that have been fetched on behalf
- of this connection. This cache may be cleared by calling the
- method.
+ Provides the underlying storage for the
+ property.
-
+
- The default databse type for this connection. This value will only
- be used if the
- flag is set.
+ Provides the underlying storage for the
+ property.
-
+
- The default databse type name for this connection. This value will only
- be used if the
- flag is set.
+ Provides the underlying storage for the
+ property.
-
+
- Default command timeout
+ Constructs a new instance of this class. Depending on the method
+ being called, the and/or
+ parameters may be null.
+
+ The name of the method that was
+ responsible for invoking this callback.
+
+
+ If the or
+ method is being called,
+ this object will contain the array related parameters for that
+ method. If the method is
+ being called, this object will contain the blob related parameters
+ for that method.
+
+
+ This may be used by the callback to set the return value for the
+ called method.
+
-
+
- Non-zero if the built-in (i.e. framework provided) connection string
- parser should be used when opening the connection.
+ The name of the method that was
+ responsible for invoking this callback.
-
-
- Constructs a new SQLiteConnection object
-
-
- Default constructor
-
+
+
+ If the or
+ method is being called,
+ this object will contain the array related parameters for that
+ method. If the method is
+ being called, this object will contain the blob related parameters
+ for that method.
+
-
+
- Initializes the connection with the specified connection string.
+ This may be used by the callback to set the return value for the
+ called method.
- The connection string to use.
-
+
- Initializes the connection with a pre-existing native connection handle.
- This constructor overload is intended to be used only by the private
- method.
+ This represents a method that will be called in response to a request to
+ bind a parameter to a command. If an exception is thrown, it will cause
+ the parameter binding operation to fail -AND- it will continue to unwind
+ the call stack.
-
- The native connection handle to use.
+
+ The instance in use.
-
- The file name corresponding to the native connection handle.
+
+ The instance in use.
-
- Non-zero if this instance owns the native connection handle and
- should dispose of it when it is no longer needed.
+
+ The flags associated with the instance
+ in use.
+
+
+ The instance being bound to the command.
+
+
+ The database type name associated with this callback.
+
+
+ The ordinal of the parameter being bound to the command.
+
+
+ The data originally used when registering this callback.
+
+
+ Non-zero if the default handling for the parameter binding call should
+ be skipped (i.e. the parameter should not be bound at all). Great care
+ should be used when setting this to non-zero.
-
+
- Initializes the connection with the specified connection string.
+ This represents a method that will be called in response to a request
+ to read a value from a data reader. If an exception is thrown, it will
+ cause the data reader operation to fail -AND- it will continue to unwind
+ the call stack.
-
- The connection string to use.
+
+ The instance in use.
-
- Non-zero to parse the connection string using the built-in (i.e.
- framework provided) parser when opening the connection.
+
+ The instance in use.
+
+
+ The flags associated with the instance
+ in use.
+
+
+ The parameter and return type data for the column being read from the
+ data reader.
+
+
+ The database type name associated with this callback.
+
+
+ The zero based index of the column being read from the data reader.
+
+
+ The data originally used when registering this callback.
+
+
+ Non-zero if the default handling for the data reader call should be
+ skipped. If this is set to non-zero and the necessary return value
+ is unavailable or unsuitable, an exception will be thrown.
-
+
- Clones the settings and connection string from an existing connection. If the existing connection is already open, this
- function will open its own connection, enumerate any attached databases of the original connection, and automatically
- attach to them.
+ This class represents the custom data type handling callbacks
+ for a single type name.
- The connection to copy the settings from.
-
+
- Raises the event.
+ Provides the underlying storage for the
+ property.
-
- The connection associated with this event. If this parameter is not
- null and the specified connection cannot raise events, then the
- registered event handlers will not be invoked.
-
-
- A that contains the event data.
-
-
+
- Creates and returns a new managed database connection handle. This
- method is intended to be used by implementations of the
- interface only. In theory, it
- could be used by other classes; however, that usage is not supported.
+ Provides the underlying storage for the
+ property.
-
- This must be a native database connection handle returned by the
- SQLite core library and it must remain valid and open during the
- entire duration of the calling method.
-
-
- The new managed database connection handle or null if it cannot be
- created.
-
-
+
- Backs up the database, using the specified database connection as the
- destination.
+ Provides the underlying storage for the
+ property.
- The destination database connection.
- The destination database name.
- The source database name.
-
- The number of pages to copy or negative to copy all remaining pages.
-
-
- The method to invoke between each step of the backup process. This
- parameter may be null (i.e. no callbacks will be performed).
-
-
- The number of milliseconds to sleep after encountering a locking error
- during the backup process. A value less than zero means that no sleep
- should be performed.
-
-
+
- Clears the per-connection cached settings.
+ Provides the underlying storage for the
+ property.
-
- The total number of per-connection settings cleared.
-
-
+
- Queries and returns the value of the specified setting, using the
- cached setting names and values for this connection, when available.
+ Provides the underlying storage for the
+ property.
-
- The name of the setting.
+
+
+
+ Constructs an instance of this class.
+
+
+ The custom paramater binding callback. This parameter may be null.
-
- The value to be returned if the setting has not been set explicitly
- or cannot be determined.
+
+ The custom data reader value callback. This parameter may be null.
-
- The value of the cached setting is stored here if found; otherwise,
- the value of is stored here.
+
+ The extra data to pass into the parameter binding callback. This
+ parameter may be null.
+
+
+ The extra data to pass into the data reader value callback. This
+ parameter may be null.
-
- Non-zero if the cached setting was found; otherwise, zero.
-
-
+
- Adds or sets the cached setting specified by
- to the value specified by .
+ Creates an instance of the class.
-
- The name of the cached setting to add or replace.
+
+ The custom paramater binding callback. This parameter may be null.
-
- The new value of the cached setting.
+
+ The custom data reader value callback. This parameter may be null.
+
+
+ The extra data to pass into the parameter binding callback. This
+ parameter may be null.
+
+
+ The extra data to pass into the data reader value callback. This
+ parameter may be null.
-
+
- Clears the per-connection type mappings.
+ The database type name that the callbacks contained in this class
+ will apply to. This value may not be null.
-
- The total number of per-connection type mappings cleared.
-
-
+
- Returns the per-connection type mappings.
+ The custom paramater binding callback. This value may be null.
-
- The per-connection type mappings -OR- null if they are unavailable.
-
-
+
- Adds a per-connection type mapping, possibly replacing one or more
- that already exist.
+ The custom data reader value callback. This value may be null.
-
- The case-insensitive database type name (e.g. "MYDATE"). The value
- of this parameter cannot be null. Using an empty string value (or
- a string value consisting entirely of whitespace) for this parameter
- is not recommended.
-
-
- The value that should be associated with the
- specified type name.
-
-
- Non-zero if this mapping should be considered to be the primary one
- for the specified .
-
-
- A negative value if nothing was done. Zero if no per-connection type
- mappings were replaced (i.e. it was a pure add operation). More than
- zero if some per-connection type mappings were replaced.
-
-
+
- Attempts to bind the specified object
- instance to this connection.
+ The extra data to pass into the parameter binding callback. This
+ value may be null.
-
- The object instance containing
- the metadata for the function to be bound.
-
-
- The object instance that implements the
- function to be bound.
-
-
+
- Creates a clone of the connection. All attached databases and user-defined functions are cloned. If the existing connection is open, the cloned connection
- will also be opened.
+ The extra data to pass into the data reader value callback. This
+ value may be null.
-
-
+
- Creates a database file. This just creates a zero-byte file which SQLite
- will turn into a database when the file is opened properly.
+ This class represents the mappings between database type names
+ and their associated custom data type handling callbacks.
- The file to create
-
+
- Raises the state change event when the state of the connection changes
+ Constructs an (empty) instance of this class.
- The new connection state. If this is different
- from the previous state, the event is
- raised.
- The event data created for the raised event, if
- it was actually raised.
-
+
- Determines and returns the fallback default isolation level when one cannot be
- obtained from an existing connection instance.
+ Event data for connection event handlers.
-
- The fallback default isolation level for this connection instance -OR-
- if it cannot be determined.
-
-
+
- Determines and returns the default isolation level for this connection instance.
+ The type of event being raised.
-
- The default isolation level for this connection instance -OR-
- if it cannot be determined.
-
-
+
- OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection.
+ The associated with this event, if any.
- This parameter is ignored.
- When TRUE, SQLite defers obtaining a write lock until a write operation is requested.
- When FALSE, a writelock is obtained immediately. The default is TRUE, but in a multi-threaded multi-writer
- environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock.
- Returns a SQLiteTransaction object.
-
+
- OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection.
+ The transaction associated with this event, if any.
- When TRUE, SQLite defers obtaining a write lock until a write operation is requested.
- When FALSE, a writelock is obtained immediately. The default is false, but in a multi-threaded multi-writer
- environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock.
- Returns a SQLiteTransaction object.
-
+
- Creates a new if one isn't already active on the connection.
+ The command associated with this event, if any.
- Supported isolation levels are Serializable, ReadCommitted and Unspecified.
-
- Unspecified will use the default isolation level specified in the connection string. If no isolation level is specified in the
- connection string, Serializable is used.
- Serializable transactions are the default. In this mode, the engine gets an immediate lock on the database, and no other threads
- may begin a transaction. Other threads may read from the database, but not write.
- With a ReadCommitted isolation level, locks are deferred and elevated as needed. It is possible for multiple threads to start
- a transaction in ReadCommitted mode, but if a thread attempts to commit a transaction while another thread
- has a ReadCommitted lock, it may timeout or cause a deadlock on both threads until both threads' CommandTimeout's are reached.
-
- Returns a SQLiteTransaction object.
-
+
- Creates a new if one isn't already
- active on the connection.
+ The data reader associated with this event, if any.
- Returns the new transaction object.
-
+
- Forwards to the local function
+ The critical handle associated with this event, if any.
- Supported isolation levels are Unspecified, Serializable, and ReadCommitted
-
-
+
- This method is not implemented; however, the
- event will still be raised.
+ Command or message text associated with this event, if any.
-
+
+
+
+ Extra data associated with this event, if any.
+
+
+
+
+ Constructs the object.
+
+ The type of event being raised.
+ The base associated
+ with this event, if any.
+ The transaction associated with this event, if any.
+ The command associated with this event, if any.
+ The data reader associated with this event, if any.
+ The critical handle associated with this event, if any.
+ The command or message text, if any.
+ The extra data, if any.
+
+
+
+ Raised when an event pertaining to a connection occurs.
+
+ The connection involved.
+ Extra information about the event.
+
+
+
+ SQLite implentation of DbConnection.
+
+
+ The property can contain the following parameter(s), delimited with a semi-colon:
+
+
+ Parameter
+ Values
+ Required
+ Default
+
+ -
+
Data Source
+
+ This may be a file name, the string ":memory:", or any supported URI (starting with SQLite 3.7.7).
+ Starting with release 1.0.86.0, in order to use more than one consecutive backslash (e.g. for a
+ UNC path), each of the adjoining backslash characters must be doubled (e.g. "\\Network\Share\test.db"
+ would become "\\\\Network\Share\test.db").
+
+ Y
+
+
+ -
+
Uri
+
+ If specified, this must be a file name that starts with "file://", "file:", or "/". Any leading
+ "file://" or "file:" prefix will be stripped off and the resulting file name will be used to open
+ the database.
+
+ N
+ null
+
+ -
+
FullUri
+
+ If specified, this must be a URI in a format recognized by the SQLite core library (starting with
+ SQLite 3.7.7). It will be passed verbatim to the SQLite core library.
+
+ N
+ null
+
+ -
+
Version
+ 3
+ N
+ 3
+
+ -
+
UseUTF16Encoding
+
+ True - The UTF-16 encoding should be used.
+
+ False - The UTF-8 encoding should be used.
+
+ N
+ False
+
+ -
+
DefaultDbType
+
+ This is the default to use when one cannot be determined based on the
+ column metadata and the configured type mappings.
+
+ N
+ null
+
+ -
+
DefaultTypeName
+
+ This is the default type name to use when one cannot be determined based on the column metadata
+ and the configured type mappings.
+
+ N
+ null
+
+ -
+
NoDefaultFlags
+
+ True - Do not combine the specified (or existing) connection flags with the value of the
+ property.
+
+ False - Combine the specified (or existing) connection flags with the value of the
+ property.
+
+ N
+ False
+
+ -
+
NoSharedFlags
+
+ True - Do not combine the specified (or existing) connection flags with the value of the
+ property.
+
+ False - Combine the specified (or existing) connection flags with the value of the
+ property.
+
+ N
+ False
+
+ -
+
VfsName
+
+ The name of the VFS to use when opening the database connection.
+ If this is not specified, the default VFS will be used.
+
+ N
+ null
+
+ -
+
ZipVfsVersion
+
+ If non-null, this is the "version" of ZipVFS to use. This requires
+ the System.Data.SQLite interop assembly -AND- primary managed assembly
+ to be compiled with the INTEROP_INCLUDE_ZIPVFS option; otherwise, this
+ property does nothing. The valid values are "v2" and "v3". Using
+ anyother value will cause an exception to be thrown. Please see the
+ ZipVFS documentation for more information on how to use this parameter.
+
+ N
+ null
+
+ -
+
DateTimeFormat
+
+ Ticks - Use the value of DateTime.Ticks.
+ ISO8601 - Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC
+ DateTime values and "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values).
+ JulianDay - The interval of time in days and fractions of a day since January 1, 4713 BC.
+ UnixEpoch - The whole number of seconds since the Unix epoch (January 1, 1970).
+ InvariantCulture - Any culture-independent string value that the .NET Framework can interpret as a valid DateTime.
+ CurrentCulture - Any string value that the .NET Framework can interpret as a valid DateTime using the current culture.
+ N
+ ISO8601
+
+ -
+
DateTimeKind
+
+ Unspecified - Not specified as either UTC or local time.
+
+ Utc - The time represented is UTC.
+
+ Local - The time represented is local time.
+
+ N
+ Unspecified
+
+ -
+
DateTimeFormatString
+
+ The exact DateTime format string to use for all formatting and parsing of all DateTime
+ values for this connection.
+
+ N
+ null
+
+ -
+
BaseSchemaName
+
+ Some base data classes in the framework (e.g. those that build SQL queries dynamically)
+ assume that an ADO.NET provider cannot support an alternate catalog (i.e. database) without supporting
+ alternate schemas as well; however, SQLite does not fit into this model. Therefore, this value is used
+ as a placeholder and removed prior to preparing any SQL statements that may contain it.
+
+ N
+ sqlite_default_schema
+
+ -
+
BinaryGUID
+
+ True - Store GUID columns in binary form
+
+ False - Store GUID columns as text
+
+ N
+ True
+
+ -
+
Cache Size
+
+ If the argument N is positive then the suggested cache size is set to N.
+ If the argument N is negative, then the number of cache pages is adjusted
+ to use approximately abs(N*4096) bytes of memory. Backwards compatibility
+ note: The behavior of cache_size with a negative N was different in SQLite
+ versions prior to 3.7.10. In version 3.7.9 and earlier, the number of
+ pages in the cache was set to the absolute value of N.
+
+ N
+ -2000
+
+ -
+
Synchronous
+
+ Normal - Normal file flushing behavior
+
+ Full - Full flushing after all writes
+
+ Off - Underlying OS flushes I/O's
+
+ N
+ Full
+
+ -
+
Page Size
+ {size in bytes}
+ N
+ 4096
+
+ -
+
Password
+
+ {password} - Using this parameter requires that the legacy CryptoAPI based
+ codec (or the SQLite Encryption Extension) be enabled at compile-time for
+ both the native interop assembly and the core managed assemblies; otherwise,
+ using this parameter may result in an exception being thrown when attempting
+ to open the connection.
+
+ N
+
+
+ -
+
HexPassword
+
+ {hexPassword} - Must contain a sequence of zero or more hexadecimal encoded
+ byte values without a leading "0x" prefix. Using this parameter requires
+ that the legacy CryptoAPI based codec (or the SQLite Encryption Extension)
+ be enabled at compile-time for both the native interop assembly and the
+ core managed assemblies; otherwise, using this parameter may result in an
+ exception being thrown when attempting to open the connection.
+
+ N
+
+
+ -
+
Enlist
+
+ Y - Automatically enlist in distributed transactions
+
+ N - No automatic enlistment
+
+ N
+ Y
+
+ -
+
Pooling
+
+ True - Use connection pooling.
+ False - Do not use connection pooling.
+ WARNING: When using the default connection pool implementation,
+ setting this property to True should be avoided by applications that make
+ use of COM (either directly or indirectly) due to possible deadlocks that
+ can occur during the finalization of some COM objects.
+
+ N
+ False
+
+ -
+
FailIfMissing
+
+ True - Don't create the database if it does not exist, throw an error instead
+
+ False - Automatically create the database if it does not exist
+
+ N
+ False
+
+ -
+
Max Page Count
+ {size in pages} - Limits the maximum number of pages (limits the size) of the database
+ N
+ 0
+
+ -
+
Legacy Format
+
+ True - Use the more compatible legacy 3.x database format
+
+ False - Use the newer 3.3x database format which compresses numbers more effectively
+
+ N
+ False
+
+ -
+
Default Timeout
+ {time in seconds} The default command timeout
+ N
+ 30
+
+ -
+
BusyTimeout
+ {time in milliseconds} Sets the busy timeout for the core library.
+ N
+ 0
+
+ -
+
WaitTimeout
+ {time in milliseconds}
+ EXPERIMENTAL -- The wait timeout to use with
+ method. This is only used when
+ waiting for the enlistment to be reset prior to enlisting in a transaction,
+ and then only when the appropriate connection flag is set.
+ N
+ 30000
+
+ -
+
Journal Mode
+
+ Delete - Delete the journal file after a commit.
+
+ Persist - Zero out and leave the journal file on disk after a
+ commit.
+
+ Off - Disable the rollback journal entirely. This saves disk I/O
+ but at the expense of database safety and integrity. If the application
+ using SQLite crashes in the middle of a transaction when this journaling
+ mode is set, then the database file will very likely go corrupt.
+
+ Truncate - Truncate the journal file to zero-length instead of
+ deleting it.
+
+ Memory - Store the journal in volatile RAM. This saves disk I/O
+ but at the expense of database safety and integrity. If the application
+ using SQLite crashes in the middle of a transaction when this journaling
+ mode is set, then the database file will very likely go corrupt.
+
+ Wal - Use a write-ahead log instead of a rollback journal.
+
+ N
+ Delete
+
+ -
+
Read Only
+
+ True - Open the database for read only access
+
+ False - Open the database for normal read/write access
+
+ N
+ False
+
+ -
+
Max Pool Size
+ The maximum number of connections for the given connection string that can be in the connection pool
+ N
+ 100
+
+ -
+
Default IsolationLevel
+ The default transaciton isolation level
+ N
+ Serializable
+
+ -
+
Foreign Keys
+ Enable foreign key constraints
+ N
+ False
+
+ -
+
Flags
+ Extra behavioral flags for the connection. See the enumeration for possible values.
+ N
+ Default
+
+ -
+
SetDefaults
+
+ True - Apply the default connection settings to the opened database.
+ False - Skip applying the default connection settings to the opened database.
+
+ N
+ True
+
+ -
+
ToFullPath
+
+ True - Attempt to expand the data source file name to a fully qualified path before opening.
+
+ False - Skip attempting to expand the data source file name to a fully qualified path before opening.
+
+ N
+ True
+
+ -
+
PrepareRetries
+
+ The maximum number of retries when preparing SQL to be executed. This
+ normally only applies to preparation errors resulting from the database
+ schema being changed.
+
+ N
+ 3
+
+ -
+
ProgressOps
+
+ The approximate number of virtual machine instructions between progress
+ events. In order for progress events to actually fire, the event handler
+ must be added to the event as well.
+
+ N
+ 0
+
+ -
+
Recursive Triggers
+
+ True - Enable the recursive trigger capability.
+ False - Disable the recursive trigger capability.
+
+ N
+ False
+
+
+
+
+
+
+ The "invalid value" for the enumeration used
+ by the property. This constant is shared
+ by this class and the SQLiteConnectionStringBuilder class.
+
+
+
+
+ The default "stub" (i.e. placeholder) base schema name to use when
+ returning column schema information. Used as the initial value of
+ the BaseSchemaName property. This should start with "sqlite_*"
+ because those names are reserved for use by SQLite (i.e. they cannot
+ be confused with the names of user objects).
+
+
+
+
+ The managed assembly containing this type.
+
+
+
+
+ Object used to synchronize access to the static instance data
+ for this class.
+
+
+
+
+ Static variable to store the connection event handlers to call.
+
+
+
+
+ The extra connection flags to be used for all opened connections.
+
+
+
+
+ The instance (for this thread) that
+ had the most recent call to .
+
+
+
+
+ Used to hold the active library version number of SQLite.
+
+
+
+
+ State of the current connection
+
+
+
+
+ The connection string
+
+
+
+
+ Nesting level of the transactions open on the connection
+
+
+
+
+ Transaction counter for the connection. Currently, this is only used
+ to build SAVEPOINT names.
+
+
+
+
+ If this flag is non-zero, the method will have
+ no effect; however, the method will continue to
+ behave as normal.
+
+
+
+
+ If set, then the connection is currently being disposed.
+
+
+
+
+ The default isolation level for new transactions
+
+
+
+
+ This object is used with lock statements to synchronize access to the
+ field, below.
+
+
+
+
+ Whether or not the connection is enlisted in a distrubuted transaction
+
+
+
+
+ The per-connection mappings between type names and
+ values. These mappings override the corresponding global mappings.
+
+
+
+
+ The per-connection mappings between type names and optional callbacks
+ for parameter binding and value reading.
+
+
+
+
+ The base SQLite object to interop with
+
+
+
+
+ The database filename minus path and extension
+
+
+
+
+ The "stub" (i.e. placeholder) base schema name to use when returning
+ column schema information.
+
+
+
+
+ The extra behavioral flags for this connection, if any. See the
+ enumeration for a list of
+ possible values.
+
+
+
+
+ The cached values for all settings that have been fetched on behalf
+ of this connection. This cache may be cleared by calling the
+ method.
+
+
+
+
+ The default databse type for this connection. This value will only
+ be used if the
+ flag is set.
+
+
+
+
+ The default databse type name for this connection. This value will only
+ be used if the
+ flag is set.
+
+
+
+
+ The name of the VFS to be used when opening the database connection.
+
+
+
+
+ Default command timeout
+
+
+
+
+ The default busy timeout to use with the SQLite core library. This is
+ only used when opening a connection.
+
+
+
+
+ The default wait timeout to use with
+ method. This is only used when waiting for the enlistment to be reset
+ prior to enlisting in a transaction, and then only when the appropriate
+ connection flag is set.
+
+
+
+
+ The maximum number of retries when preparing SQL to be executed. This
+ normally only applies to preparation errors resulting from the database
+ schema being changed.
+
+
+
+
+ The approximate number of virtual machine instructions between progress
+ events. In order for progress events to actually fire, the event handler
+ must be added to the event as
+ well. This value will only be used when opening the database.
+
+
+
+
+ Non-zero if the built-in (i.e. framework provided) connection string
+ parser should be used when opening the connection.
+
+
+
+
+ This event is raised whenever the database is opened or closed.
+
+
+
+
+ Constructs a new SQLiteConnection object
+
+
+ Default constructor
+
+
+
+
+ Initializes the connection with the specified connection string.
+
+ The connection string to use.
+
+
+
+ Initializes the connection with a pre-existing native connection handle.
+ This constructor overload is intended to be used only by the private
+ method.
+
+
+ The native connection handle to use.
+
+
+ The file name corresponding to the native connection handle.
+
+
+ Non-zero if this instance owns the native connection handle and
+ should dispose of it when it is no longer needed.
+
+
+
+
+ Initializes the connection with the specified connection string.
+
+
+ The connection string to use.
+
+
+ Non-zero to parse the connection string using the built-in (i.e.
+ framework provided) parser when opening the connection.
+
+
+
+
+ Clones the settings and connection string from an existing connection. If the existing connection is already open, this
+ function will open its own connection, enumerate any attached databases of the original connection, and automatically
+ attach to them.
+
+ The connection to copy the settings from.
+
+
+
+ Attempts to lookup the native handle associated with the connection. An exception will
+ be thrown if this cannot be accomplished.
+
+
+ The connection associated with the desired native handle.
+
+
+ The native handle associated with the connection or if it
+ cannot be determined.
+
+
+
+
+ Raises the event.
+
+
+ The connection associated with this event. If this parameter is not
+ null and the specified connection cannot raise events, then the
+ registered event handlers will not be invoked.
+
+
+ A that contains the event data.
+
+
+
+
+ This event is raised when events related to the lifecycle of a
+ SQLiteConnection object occur.
+
+
+
+
+ This property is used to obtain or set the custom connection pool
+ implementation to use, if any. Setting this property to null will
+ cause the default connection pool implementation to be used.
+
+
+
+
+ Creates and returns a new managed database connection handle. This
+ method is intended to be used by implementations of the
+ interface only. In theory, it
+ could be used by other classes; however, that usage is not supported.
+
+
+ This must be a native database connection handle returned by the
+ SQLite core library and it must remain valid and open during the
+ entire duration of the calling method.
+
+
+ The new managed database connection handle or null if it cannot be
+ created.
+
+
+
+
+ Backs up the database, using the specified database connection as the
+ destination.
+
+ The destination database connection.
+ The destination database name.
+ The source database name.
+
+ The number of pages to copy at a time -OR- a negative value to copy all
+ pages. When a negative value is used, the
+ may never be invoked.
+
+
+ The method to invoke between each step of the backup process. This
+ parameter may be null (i.e. no callbacks will be performed). If the
+ callback returns false -OR- throws an exception, the backup is canceled.
+
+
+ The number of milliseconds to sleep after encountering a locking error
+ during the backup process. A value less than zero means that no sleep
+ should be performed.
+
+
+
+
+ Clears the per-connection cached settings.
+
+
+ The total number of per-connection settings cleared.
+
+
+
+
+ Queries and returns the value of the specified setting, using the
+ cached setting names and values for this connection, when available.
+
+
+ The name of the setting.
+
+
+ The value to be returned if the setting has not been set explicitly
+ or cannot be determined.
+
+
+ The value of the cached setting is stored here if found; otherwise,
+ the value of is stored here.
+
+
+ Non-zero if the cached setting was found; otherwise, zero.
+
+
+
+
+ Adds or sets the cached setting specified by
+ to the value specified by .
+
+
+ The name of the cached setting to add or replace.
+
+
+ The new value of the cached setting.
+
+
+
+
+ Clears the per-connection type mappings.
+
+
+ The total number of per-connection type mappings cleared.
+
+
+
+
+ Returns the per-connection type mappings.
+
+
+ The per-connection type mappings -OR- null if they are unavailable.
+
+
+
+
+ Adds a per-connection type mapping, possibly replacing one or more
+ that already exist.
+
+
+ The case-insensitive database type name (e.g. "MYDATE"). The value
+ of this parameter cannot be null. Using an empty string value (or
+ a string value consisting entirely of whitespace) for this parameter
+ is not recommended.
+
+
+ The value that should be associated with the
+ specified type name.
+
+
+ Non-zero if this mapping should be considered to be the primary one
+ for the specified .
+
+
+ A negative value if nothing was done. Zero if no per-connection type
+ mappings were replaced (i.e. it was a pure add operation). More than
+ zero if some per-connection type mappings were replaced.
+
+
+
+
+ Clears the per-connection type callbacks.
+
+
+ The total number of per-connection type callbacks cleared.
+
+
+
+
+ Attempts to get the per-connection type callbacks for the specified
+ database type name.
+
+
+ The database type name.
+
+
+ Upon success, this parameter will contain the object holding the
+ callbacks for the database type name. Upon failure, this parameter
+ will be null.
+
+
+ Non-zero upon success; otherwise, zero.
+
+
+
+
+ Sets, resets, or clears the per-connection type callbacks for the
+ specified database type name.
+
+
+ The database type name.
+
+
+ The object holding the callbacks for the database type name. If
+ this parameter is null, any callbacks for the database type name
+ will be removed if they are present.
+
+
+ Non-zero if callbacks were set or removed; otherwise, zero.
+
+
+
+
+ Attempts to bind the specified object
+ instance to this connection.
+
+
+ The object instance containing
+ the metadata for the function to be bound.
+
+
+ The object instance that implements the
+ function to be bound.
+
+
+
+
+ Attempts to bind the specified object
+ instance to this connection.
+
+
+ The object instance containing
+ the metadata for the function to be bound.
+
+
+ A object instance that helps implement the
+ function to be bound. For scalar functions, this corresponds to the
+ type. For aggregate functions,
+ this corresponds to the type. For
+ collation functions, this corresponds to the
+ type.
+
+
+ A object instance that helps implement the
+ function to be bound. For aggregate functions, this corresponds to the
+ type. For other callback types, it
+ is not used and must be null.
+
+
+
+
+ Attempts to unbind the specified object
+ instance to this connection.
+
+
+ The object instance containing
+ the metadata for the function to be unbound.
+
+ Non-zero if the function was unbound.
+
+
+
+ This method unbinds all registered (known) functions -OR- all previously
+ bound user-defined functions from this connection.
+
+
+ Non-zero to unbind all registered (known) functions -OR- zero to unbind
+ all functions currently bound to the connection.
+
+
+ Non-zero if all the specified user-defined functions were unbound.
+
+
+
+
+ Parses a connection string into component parts using the custom
+ connection string parser. An exception may be thrown if the syntax
+ of the connection string is incorrect.
+
+
+ The connection string to parse.
+
+
+ Non-zero to parse the connection string using the algorithm provided
+ by the framework itself. This is not applicable when running on the
+ .NET Compact Framework.
+
+
+ Non-zero if names are allowed without values.
+
+
+ The list of key/value pairs corresponding to the parameters specified
+ within the connection string.
+
+
+
+
+ Parses a connection string into component parts using the custom
+ connection string parser. An exception may be thrown if the syntax
+ of the connection string is incorrect.
+
+
+ The connection that will be using the parsed connection string.
+
+
+ The connection string to parse.
+
+
+ Non-zero to parse the connection string using the algorithm provided
+ by the framework itself. This is not applicable when running on the
+ .NET Compact Framework.
+
+
+ Non-zero if names are allowed without values.
+
+
+ The list of key/value pairs corresponding to the parameters specified
+ within the connection string.
+
+
+
+
+ Disposes and finalizes the connection, if applicable.
+
+
+
+
+ Cleans up resources (native and managed) associated with the current instance.
+
+
+ Zero when being disposed via garbage collection; otherwise, non-zero.
+
+
+
+
+ Creates a clone of the connection. All attached databases and user-defined functions are cloned. If the existing connection is open, the cloned connection
+ will also be opened.
+
+
+
+
+
+ Creates a database file. This just creates a zero-byte file which SQLite
+ will turn into a database when the file is opened properly.
+
+ The file to create
+
+
+
+ Raises the state change event when the state of the connection changes
+
+ The new connection state. If this is different
+ from the previous state, the event is
+ raised.
+ The event data created for the raised event, if
+ it was actually raised.
+
+
+
+ Determines and returns the fallback default isolation level when one cannot be
+ obtained from an existing connection instance.
+
+
+ The fallback default isolation level for this connection instance -OR-
+ if it cannot be determined.
+
+
+
+
+ Determines and returns the default isolation level for this connection instance.
+
+
+ The default isolation level for this connection instance -OR-
+ if it cannot be determined.
+
+
+
+
+ OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection.
+
+ This parameter is ignored.
+ When TRUE, SQLite defers obtaining a write lock until a write operation is requested.
+ When FALSE, a writelock is obtained immediately. The default is TRUE, but in a multi-threaded multi-writer
+ environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock.
+ Returns a SQLiteTransaction object.
+
+
+
+ OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection.
+
+ When TRUE, SQLite defers obtaining a write lock until a write operation is requested.
+ When FALSE, a writelock is obtained immediately. The default is false, but in a multi-threaded multi-writer
+ environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock.
+ Returns a SQLiteTransaction object.
+
+
+
+ Creates a new if one isn't already active on the connection.
+
+ Supported isolation levels are Serializable, ReadCommitted and Unspecified.
+
+ Unspecified will use the default isolation level specified in the connection string. If no isolation level is specified in the
+ connection string, Serializable is used.
+ Serializable transactions are the default. In this mode, the engine gets an immediate lock on the database, and no other threads
+ may begin a transaction. Other threads may read from the database, but not write.
+ With a ReadCommitted isolation level, locks are deferred and elevated as needed. It is possible for multiple threads to start
+ a transaction in ReadCommitted mode, but if a thread attempts to commit a transaction while another thread
+ has a ReadCommitted lock, it may timeout or cause a deadlock on both threads until both threads' CommandTimeout's are reached.
+
+ Returns a SQLiteTransaction object.
+
+
+
+ Creates a new if one isn't already
+ active on the connection.
+
+ Returns the new transaction object.
+
+
+
+ Forwards to the local function
+
+ Supported isolation levels are Unspecified, Serializable, and ReadCommitted
+
+
+
+
+ This method is not implemented; however, the
+ event will still be raised.
+
+
- When the database connection is closed, all commands linked to this connection are automatically reset.
+ When the database connection is closed, all commands linked to this connection are automatically reset.
+
+
+
+
+ Returns the number of pool entries for the file name associated with this connection.
+
+
+
+
+ Clears the connection pool associated with the connection. Any other active connections using the same database file
+ will be discarded instead of returned to the pool when they are closed.
+
+
+
+
+
+ Clears all connection pools. Any active connections will be discarded instead of sent to the pool when they are closed.
+
+
+
+
+ The connection string containing the parameters for the connection
+
+
+ For the complete list of supported connection string properties,
+ please see .
+
+
+
+
+ Create a new and associate it with this connection.
+
+ Returns a new command object already assigned to this connection.
+
+
+
+ Forwards to the local function.
+
+
+
+
+
+ Attempts to create a new object instance
+ using this connection and the specified database name.
+
+
+ The name of the database for the newly created session.
+
+
+ The newly created session -OR- null if it cannot be created.
+
+
+
+
+ Attempts to create a new object instance
+ using this connection and the specified raw data.
+
+
+ The raw data that contains a change set (or patch set).
+
+
+ The newly created change set -OR- null if it cannot be created.
+
+
+
+
+ Attempts to create a new object instance
+ using this connection and the specified raw data.
+
+
+ The raw data that contains a change set (or patch set).
+
+
+ The flags used to create the change set iterator.
+
+
+ The newly created change set -OR- null if it cannot be created.
+
+
+
+
+ Attempts to create a new object instance
+ using this connection and the specified stream.
+
+
+ The stream where the raw data that contains a change set (or patch set)
+ may be read.
+
+
+ The stream where the raw data that contains a change set (or patch set)
+ may be written.
+
+
+ The newly created change set -OR- null if it cannot be created.
+
+
+
+
+ Attempts to create a new object instance
+ using this connection and the specified stream.
+
+
+ The stream where the raw data that contains a change set (or patch set)
+ may be read.
+
+
+ The stream where the raw data that contains a change set (or patch set)
+ may be written.
+
+
+ The flags used to create the change set iterator.
+
+
+ The newly created change set -OR- null if it cannot be created.
+
+
+
+
+ Attempts to create a new object
+ instance using this connection.
+
+
+ The newly created change group -OR- null if it cannot be created.
+
+
+
+
+ Returns the data source file name without extension or path.
+
+
+
+
+ Returns the fully qualified path and file name for the currently open
+ database, if any.
+
+
+
+
+ Returns the string "main".
+
+
+
+
+ Determines if the legacy connection string parser should be used.
+
+
+ The connection that will be using the parsed connection string.
+
+
+ Non-zero if the legacy connection string parser should be used.
+
+
+
+
+ Parses a connection string into component parts using the custom
+ connection string parser. An exception may be thrown if the syntax
+ of the connection string is incorrect.
+
+
+ The connection string to parse.
+
+
+ Non-zero if names are allowed without values.
+
+
+ The list of key/value pairs corresponding to the parameters specified
+ within the connection string.
+
+
+
+
+ Parses a connection string into component parts using the custom
+ connection string parser. An exception may be thrown if the syntax
+ of the connection string is incorrect.
+
+
+ The connection that will be using the parsed connection string.
+
+
+ The connection string to parse.
+
+
+ Non-zero if names are allowed without values.
+
+
+ The list of key/value pairs corresponding to the parameters specified
+ within the connection string.
+
+
+
+
+ Parses a connection string using the built-in (i.e. framework provided)
+ connection string parser class and returns the key/value pairs. An
+ exception may be thrown if the connection string is invalid or cannot be
+ parsed. When compiled for the .NET Compact Framework, the custom
+ connection string parser is always used instead because the framework
+ provided one is unavailable there.
+
+
+ The connection that will be using the parsed connection string.
+
+
+ The connection string to parse.
+
+
+ Non-zero to throw an exception if any connection string values are not of
+ the type. This is not applicable when running on
+ the .NET Compact Framework.
+
+ The list of key/value pairs.
+
+
+
+ Manual distributed transaction enlistment support
+
+ The distributed transaction to enlist in
+
+
+
+ EXPERIMENTAL --
+ Waits for the enlistment associated with this connection to be reset.
+ This method always throws when
+ running on the .NET Compact Framework.
+
+
+ The approximate maximum number of milliseconds to wait before timing
+ out the wait operation.
+
+
+ The return value to use if the connection has been disposed; if this
+ value is null, will be raised
+ if the connection has been disposed.
+
+
+ Non-zero if the enlistment assciated with this connection was reset;
+ otherwise, zero. It should be noted that this method returning a
+ non-zero value does not necessarily guarantee that the connection
+ can enlist in a new transaction (i.e. due to potentical race with
+ other threads); therefore, callers should generally use try/catch
+ when calling the method.
+
+
+
+
+ Looks for a key in the array of key/values of the parameter string. If not found, return the specified default value
+
+ The list to look in
+ The key to find
+ The default value to return if the key is not found
+ The value corresponding to the specified key, or the default value if not found.
+
+
+
+ Attempts to convert the string value to an enumerated value of the specified type.
+
+ The enumerated type to convert the string value to.
+ The string value to be converted.
+ Non-zero to make the conversion case-insensitive.
+ The enumerated value upon success or null upon error.
+
+
+
+ Attempts to convert an input string into a byte value.
+
+
+ The string value to be converted.
+
+
+ The number styles to use for the conversion.
+
+
+ Upon sucess, this will contain the parsed byte value.
+ Upon failure, the value of this parameter is undefined.
+
+
+ Non-zero upon success; zero on failure.
+
+
+
+
+ Change a configuration option value for the database.
+
+
+ The database configuration option to change.
+
+
+ The new value for the specified configuration option.
+
+
+
+
+ Enables or disables extension loading.
+
+
+ True to enable loading of extensions, false to disable.
+
+
+
+
+ Loads a SQLite extension library from the named dynamic link library file.
+
+
+ The name of the dynamic link library file containing the extension.
+
+
+
+
+ Loads a SQLite extension library from the named dynamic link library file.
+
+
+ The name of the dynamic link library file containing the extension.
+
+
+ The name of the exported function used to initialize the extension.
+ If null, the default "sqlite3_extension_init" will be used.
+
+
+
+
+ Creates a disposable module containing the implementation of a virtual
+ table.
+
+
+ The module object to be used when creating the disposable module.
+
+
+
+
+ Parses a string containing a sequence of zero or more hexadecimal
+ encoded byte values and returns the resulting byte array. The
+ "0x" prefix is not allowed on the input string.
+
+
+ The input string containing zero or more hexadecimal encoded byte
+ values.
+
+
+ A byte array containing the parsed byte values or null if an error
+ was encountered.
+
+
+
+
+ Creates and returns a string containing the hexadecimal encoded byte
+ values from the input array.
+
+
+ The input array of bytes.
+
+
+ The resulting string or null upon failure.
+
+
+
+
+ Parses a string containing a sequence of zero or more hexadecimal
+ encoded byte values and returns the resulting byte array. The
+ "0x" prefix is not allowed on the input string.
+
+
+ The input string containing zero or more hexadecimal encoded byte
+ values.
+
+
+ Upon failure, this will contain an appropriate error message.
+
+
+ A byte array containing the parsed byte values or null if an error
+ was encountered.
+
+
+
+
+ This method figures out what the default connection pool setting should
+ be based on the connection flags. When present, the "Pooling" connection
+ string property value always overrides the value returned by this method.
+
+
+ Non-zero if the connection pool should be enabled by default; otherwise,
+ zero.
+
+
+
+
+ Determines the transaction isolation level that should be used by
+ the caller, primarily based upon the one specified by the caller.
+ If mapping of transaction isolation levels is enabled, the returned
+ transaction isolation level may be significantly different than the
+ originally specified one.
+
+
+ The originally specified transaction isolation level.
+
+
+ The transaction isolation level that should be used.
+
+
+
+
+ Opens the connection using the parameters found in the .
+
+
+
+
+ Opens the connection using the parameters found in the and then returns it.
+
+ The current connection object.
+
+
+
+ Gets/sets the default command timeout for newly-created commands. This is especially useful for
+ commands used internally such as inside a SQLiteTransaction, where setting the timeout is not possible.
+ This can also be set in the ConnectionString with "Default Timeout"
+
+
+
+
+ Gets/sets the default busy timeout to use with the SQLite core library. This is only used when
+ opening a connection.
+
+
+
+
+ EXPERIMENTAL --
+ The wait timeout to use with method.
+ This is only used when waiting for the enlistment to be reset prior to
+ enlisting in a transaction, and then only when the appropriate connection
+ flag is set.
+
+
+
+
+ The maximum number of retries when preparing SQL to be executed. This
+ normally only applies to preparation errors resulting from the database
+ schema being changed.
+
+
+
+
+ The approximate number of virtual machine instructions between progress
+ events. In order for progress events to actually fire, the event handler
+ must be added to the event as
+ well. This value will only be used when the underlying native progress
+ callback needs to be changed.
+
+
+
+
+ Non-zero if the built-in (i.e. framework provided) connection string
+ parser should be used when opening the connection.
+
+
+
+
+ Gets/sets the extra behavioral flags for this connection. See the
+ enumeration for a list of
+ possible values.
+
+
+
+
+ Gets/sets the default database type for this connection. This value
+ will only be used when not null.
+
+
+
+
+ Gets/sets the default database type name for this connection. This
+ value will only be used when not null.
+
+
+
+
+ Gets/sets the VFS name for this connection. This value will only be
+ used when opening the database.
+
+
+
+
+ Returns non-zero if the underlying native connection handle is
+ owned by this instance.
+
+
+
+
+ Returns the version of the underlying SQLite database engine
+
+
+
+
+ Returns the rowid of the most recent successful INSERT into the database from this connection.
+
+
+
+
+ This method causes any pending database operation to abort and return at
+ its earliest opportunity. This routine is typically called in response
+ to a user action such as pressing "Cancel" or Ctrl-C where the user wants
+ a long query operation to halt immediately. It is safe to call this
+ routine from any thread. However, it is not safe to call this routine
+ with a database connection that is closed or might close before this method
+ returns.
+
+
+
+
+ Returns the number of rows changed by the last INSERT, UPDATE, or DELETE statement executed on
+ this connection.
+
+
+
+
+ Checks if this connection to the specified database should be considered
+ read-only. An exception will be thrown if the database name specified
+ via cannot be found.
+
+
+ The name of a database associated with this connection -OR- null for the
+ main database.
+
+
+ Non-zero if this connection to the specified database should be considered
+ read-only.
+
+
+
+
+ Returns non-zero if the given database connection is in autocommit mode.
+ Autocommit mode is on by default. Autocommit mode is disabled by a BEGIN
+ statement. Autocommit mode is re-enabled by a COMMIT or ROLLBACK.
+
+
+
+
+ Returns the amount of memory (in bytes) currently in use by the SQLite core library.
+
+
+
+
+ Returns the maximum amount of memory (in bytes) used by the SQLite core library since the high-water mark was last reset.
+
+
+
+
+ Returns various global memory statistics for the SQLite core library via
+ a dictionary of key/value pairs. Currently, only the "MemoryUsed" and
+ "MemoryHighwater" keys are returned and they have values that correspond
+ to the values that could be obtained via the
+ and connection properties.
+
+
+ This dictionary will be populated with the global memory statistics. It
+ will be created if necessary.
+
+
+
+
+ Attempts to free as much heap memory as possible for this database connection.
+
+
+
+
+ Attempts to free N bytes of heap memory by deallocating non-essential memory
+ allocations held by the database library. Memory used to cache database pages
+ to improve performance is an example of non-essential memory. This is a no-op
+ returning zero if the SQLite core library was not compiled with the compile-time
+ option SQLITE_ENABLE_MEMORY_MANAGEMENT. Optionally, attempts to reset and/or
+ compact the Win32 native heap, if applicable.
+
+
+ The requested number of bytes to free.
+
+
+ Non-zero to attempt a heap reset.
+
+
+ Non-zero to attempt heap compaction.
+
+
+ The number of bytes actually freed. This value may be zero.
+
+
+ This value will be non-zero if the heap reset was successful.
+
+
+ The size of the largest committed free block in the heap, in bytes.
+ This value will be zero unless heap compaction is enabled.
+
+
+ A standard SQLite return code (i.e. zero for success and non-zero
+ for failure).
+
+
+
+
+ Sets the status of the memory usage tracking subsystem in the SQLite core library. By default, this is enabled.
+ If this is disabled, memory usage tracking will not be performed. This is not really a per-connection value, it is
+ global to the process.
+
+ Non-zero to enable memory usage tracking, zero otherwise.
+ A standard SQLite return code (i.e. zero for success and non-zero for failure).
+
+
+
+ Returns a string containing the define constants (i.e. compile-time
+ options) used to compile the core managed assembly, delimited with
+ spaces.
+
+
+
+
+ Returns the version of the underlying SQLite core library.
+
+
+
+
+ This method returns the string whose value is the same as the
+ SQLITE_SOURCE_ID C preprocessor macro used when compiling the
+ SQLite core library.
+
+
+
+
+ Returns a string containing the compile-time options used to
+ compile the SQLite core native library, delimited with spaces.
+
+
+
+
+ This method returns the version of the interop SQLite assembly
+ used. If the SQLite interop assembly is not in use or the
+ necessary information cannot be obtained for any reason, a null
+ value may be returned.
+
+
+
+
+ This method returns the string whose value contains the unique
+ identifier for the source checkout used to build the interop
+ assembly. If the SQLite interop assembly is not in use or the
+ necessary information cannot be obtained for any reason, a null
+ value may be returned.
+
+
+
+
+ Returns a string containing the compile-time options used to
+ compile the SQLite interop assembly, delimited with spaces.
+
+
+
+
+ This method returns the version of the managed components used
+ to interact with the SQLite core library. If the necessary
+ information cannot be obtained for any reason, a null value may
+ be returned.
+
+
+
+
+ This method returns the string whose value contains the unique
+ identifier for the source checkout used to build the managed
+ components currently executing. If the necessary information
+ cannot be obtained for any reason, a null value may be returned.
+
+
+
+
+ Queries and returns the value of the specified setting, using the
+ cached setting names and values for the last connection that used
+ the method, when available.
+
+
+ The name of the setting.
+
+
+ The value to be returned if the setting has not been set explicitly
+ or cannot be determined.
+
+
+ The value of the cached setting is stored here if found; otherwise,
+ the value of is stored here.
+
+
+ Non-zero if the cached setting was found; otherwise, zero.
+
+
+
+
+ Adds or sets the cached setting specified by
+ to the value specified by using the cached
+ setting names and values for the last connection that used the
+ method, when available.
+
+
+ The name of the cached setting to add or replace.
+
+
+ The new value of the cached setting.
+
+
+
+
+ The default connection flags to be used for all opened connections
+ when they are not present in the connection string.
+
+
+
+
+ The extra connection flags to be used for all opened connections.
+
+
+
+
+ Returns the state of the connection.
+
+
+
+
+ Passes a shutdown request to the SQLite core library. Does not throw
+ an exception if the shutdown request fails.
+
+
+ A standard SQLite return code (i.e. zero for success and non-zero for
+ failure).
+
+
+
+
+ Passes a shutdown request to the SQLite core library. Throws an
+ exception if the shutdown request fails and the no-throw parameter
+ is non-zero.
+
+
+ Non-zero to reset the database and temporary directories to their
+ default values, which should be null for both.
+
+
+ When non-zero, throw an exception if the shutdown request fails.
+
+
+
+ Enables or disables extended result codes returned by SQLite
+
+
+ Enables or disables extended result codes returned by SQLite
+
+
+ Enables or disables extended result codes returned by SQLite
+
+
+ Add a log message via the SQLite sqlite3_log interface.
+
+
+ Add a log message via the SQLite sqlite3_log interface.
+
+
+
+ Queries or modifies the number of retries or the retry interval (in milliseconds) for
+ certain I/O operations that may fail due to anti-virus software.
+
+ The number of times to retry the I/O operation. A negative value
+ will cause the current count to be queried and replace that negative value.
+ The number of milliseconds to wait before retrying the I/O
+ operation. This number is multiplied by the number of retry attempts so far to come
+ up with the final number of milliseconds to wait. A negative value will cause the
+ current interval to be queried and replace that negative value.
+ Zero for success, non-zero for error.
+
+
+
+ Sets the chunk size for the primary file associated with this database
+ connection.
+
+
+ The new chunk size for the main database, in bytes.
+
+
+ Zero for success, non-zero for error.
+
+
+
+
+ Removes one set of surrounding single -OR- double quotes from the string
+ value and returns the resulting string value. If the string is null, empty,
+ or contains quotes that are not balanced, nothing is done and the original
+ string value will be returned.
+
+ The string value to process.
+
+ The string value, modified to remove one set of surrounding single -OR-
+ double quotes, if applicable.
+
+
+
+
+ Determines the directory to be used when dealing with the "|DataDirectory|"
+ macro in a database file name.
+
+
+ The directory to use in place of the "|DataDirectory|" macro -OR- null if it
+ cannot be determined.
+
+
+
+
+ Expand the filename of the data source, resolving the |DataDirectory|
+ macro as appropriate.
+
+ The database filename to expand
+
+ Non-zero if the returned file name should be converted to a full path
+ (except when using the .NET Compact Framework).
+
+ The expanded path and filename of the filename
+
+
+
+ The following commands are used to extract schema information out of the database. Valid schema types are:
+
+ -
+
MetaDataCollections
+
+ -
+
DataSourceInformation
+
+ -
+
Catalogs
+
+ -
+
Columns
+
+ -
+
ForeignKeys
+
+ -
+
Indexes
+
+ -
+
IndexColumns
+
+ -
+
Tables
+
+ -
+
Views
+
+ -
+
ViewColumns
+
+
+
+
+ Returns the MetaDataCollections schema
+
+ A DataTable of the MetaDataCollections schema
+
+
+
+ Returns schema information of the specified collection
+
+ The schema collection to retrieve
+ A DataTable of the specified collection
+
+
+
+ Retrieves schema information using the specified constraint(s) for the specified collection
+
+ The collection to retrieve.
+
+ The restrictions to impose. Typically, this may include:
+
+
+ restrictionValues element index
+ usage
+
+ -
+
0
+ The database (or catalog) name, if applicable.
+
+ -
+
1
+ The schema name. This is not used by this provider.
+
+ -
+
2
+ The table name, if applicable.
+
+ -
+
3
+
+ Depends on .
+ When "IndexColumns", it is the index name; otherwise, it is the column name.
+
+
+ -
+
4
+
+ Depends on .
+ When "IndexColumns", it is the column name; otherwise, it is not used.
+
+
+
+
+ A DataTable of the specified collection
+
+
+
+ Builds a MetaDataCollections schema datatable
+
+ DataTable
+
+
+
+ Builds a DataSourceInformation datatable
+
+ DataTable
+
+
+
+ Build a Columns schema
+
+ The catalog (attached database) to query, can be null
+ The table to retrieve schema information for, can be null
+ The column to retrieve schema information for, can be null
+ DataTable
+
+
+
+ Returns index information for the given database and catalog
+
+ The catalog (attached database) to query, can be null
+ The name of the index to retrieve information for, can be null
+ The table to retrieve index information for, can be null
+ DataTable
+
+
+
+ Retrieves table schema information for the database and catalog
+
+ The catalog (attached database) to retrieve tables on
+ The table to retrieve, can be null
+ The table type, can be null
+ DataTable
+
+
+
+ Retrieves view schema information for the database
+
+ The catalog (attached database) to retrieve views on
+ The view name, can be null
+ DataTable
+
+
+
+ Retrieves catalog (attached databases) schema information for the database
+
+ The catalog to retrieve, can be null
+ DataTable
+
+
+
+ Returns the base column information for indexes in a database
+
+ The catalog to retrieve indexes for (can be null)
+ The table to restrict index information by (can be null)
+ The index to restrict index information by (can be null)
+ The source column to restrict index information by (can be null)
+ A DataTable containing the results
+
+
+
+ Returns detailed column information for a specified view
+
+ The catalog to retrieve columns for (can be null)
+ The view to restrict column information by (can be null)
+ The source column to restrict column information by (can be null)
+ A DataTable containing the results
+
+
+
+ Retrieves foreign key information from the specified set of filters
+
+ An optional catalog to restrict results on
+ An optional table to restrict results on
+ An optional foreign key name to restrict results on
+ A DataTable with the results of the query
+
+
+
+ This event is raised periodically during long running queries. Changing
+ the value of the property will
+ determine if the operation in progress will continue or be interrupted.
+ For the entire duration of the event, the associated connection and
+ statement objects must not be modified, either directly or indirectly, by
+ the called code.
+
+
+
+
+ This event is raised whenever SQLite encounters an action covered by the
+ authorizer during query preparation. Changing the value of the
+ property will determine if
+ the specific action will be allowed, ignored, or denied. For the entire
+ duration of the event, the associated connection and statement objects
+ must not be modified, either directly or indirectly, by the called code.
+
+
+
+
+ This event is raised whenever SQLite makes an update/delete/insert into the database on
+ this connection. It only applies to the given connection.
+
+
+
+
+ This event is raised whenever SQLite is committing a transaction.
+ Return non-zero to trigger a rollback.
+
+
+
+
+ This event is raised whenever SQLite statement first begins executing on
+ this connection. It only applies to the given connection.
+
+
+
+
+ This event is raised whenever SQLite is rolling back a transaction.
+
+
+
+
+ Returns the instance.
+
+
+
+
+ The I/O file cache flushing behavior for the connection
+
+
+
+
+ Normal file flushing at critical sections of the code
+
+
+
+
+ Full file flushing after every write operation
+
+
+
+
+ Use the default operating system's file flushing, SQLite does not explicitly flush the file buffers after writing
+
+
+
+
+ Raised each time the number of virtual machine instructions is
+ approximately equal to the value of the
+ property.
+
+ The connection performing the operation.
+ A that contains the
+ event data.
+
+
+
+ Raised when authorization is required to perform an action contained
+ within a SQL query.
+
+ The connection performing the action.
+ A that contains the
+ event data.
+
+
+
+ Raised when a transaction is about to be committed. To roll back a transaction, set the
+ rollbackTrans boolean value to true.
+
+ The connection committing the transaction
+ Event arguments on the transaction
+
+
+
+ Raised when data is inserted, updated and deleted on a given connection
+
+ The connection committing the transaction
+ The event parameters which triggered the event
+
+
+
+ Raised when a statement first begins executing on a given connection
+
+ The connection executing the statement
+ Event arguments of the trace
+
+
+
+ Raised between each backup step.
+
+
+ The source database connection.
+
+
+ The source database name.
+
+
+ The destination database connection.
+
+
+ The destination database name.
+
+
+ The number of pages copied with each step.
+
+
+ The number of pages remaining to be copied.
+
+
+ The total number of pages in the source database.
+
+
+ Set to true if the operation needs to be retried due to database
+ locking issues; otherwise, set to false.
+
+
+ True to continue with the backup process or false to halt the backup
+ process, rolling back any changes that have been made so far.
+
+
+
+
+ The event data associated with progress reporting events.
+
+
+
+
+ The user-defined native data associated with this event. Currently,
+ this will always contain the value of .
+
+
+
+
+ The return code for the current call into the progress callback.
+
+
+
+
+ Constructs an instance of this class with default property values.
+
+
+
+
+ Constructs an instance of this class with specific property values.
+
+
+ The user-defined native data associated with this event.
+
+
+ The progress return code.
+
+
+
+
+ The data associated with a call into the authorizer.
+
+
+
+
+ The user-defined native data associated with this event. Currently,
+ this will always contain the value of .
+
+
+
+
+ The action code responsible for the current call into the authorizer.
+
+
+
+
+ The first string argument for the current call into the authorizer.
+ The exact value will vary based on the action code, see the
+ enumeration for possible
+ values.
+
+
+
+
+ The second string argument for the current call into the authorizer.
+ The exact value will vary based on the action code, see the
+ enumeration for possible
+ values.
+
+
+
+
+ The database name for the current call into the authorizer, if
+ applicable.
+
+
+
+
+ The name of the inner-most trigger or view that is responsible for
+ the access attempt or a null value if this access attempt is directly
+ from top-level SQL code.
+
+
+
+
+ The return code for the current call into the authorizer.
+
+
+
+
+ Constructs an instance of this class with default property values.
+
+
+
+
+ Constructs an instance of this class with specific property values.
+
+
+ The user-defined native data associated with this event.
+
+
+ The authorizer action code.
+
+
+ The first authorizer argument.
+
+
+ The second authorizer argument.
+
+
+ The database name, if applicable.
+
+
+ The name of the inner-most trigger or view that is responsible for
+ the access attempt or a null value if this access attempt is directly
+ from top-level SQL code.
+
+
+ The authorizer return code.
+
+
+
+
+ Whenever an update event is triggered on a connection, this enum will indicate
+ exactly what type of operation is being performed.
+
+
+
+
+ A row is being deleted from the given database and table
+
+
+
+
+ A row is being inserted into the table.
+
+
+
+
+ A row is being updated in the table.
+
+
+
+
+ Passed during an Update callback, these event arguments detail the type of update operation being performed
+ on the given connection.
+
+
+
+
+ The name of the database being updated (usually "main" but can be any attached or temporary database)
+
+
+
+
+ The name of the table being updated
+
+
+
+
+ The type of update being performed (insert/update/delete)
+
+
+
+
+ The RowId affected by this update.
+
+
+
+
+ Event arguments raised when a transaction is being committed
+
+
+
+
+ Set to true to abort the transaction and trigger a rollback
+
+
+
+
+ Passed during an Trace callback, these event arguments contain the UTF-8 rendering of the SQL statement text
+
+
+
+
+ SQL statement text as the statement first begins executing
+
+
+
+
+ This interface represents a custom connection pool implementation
+ usable by System.Data.SQLite.
+
+
+
+
+ Counts the number of pool entries matching the specified file name.
+
+
+ The file name to match or null to match all files.
+
+
+ The pool entry counts for each matching file.
+
+
+ The total number of connections successfully opened from any pool.
+
+
+ The total number of connections successfully closed from any pool.
+
+
+ The total number of pool entries for all matching files.
+
+
+
+
+ Disposes of all pooled connections associated with the specified
+ database file name.
+
+
+ The database file name.
+
+
+
+
+ Disposes of all pooled connections.
+
+
+
+
+ Adds a connection to the pool of those associated with the
+ specified database file name.
+
+
+ The database file name.
+
+
+ The database connection handle.
+
+
+ The connection pool version at the point the database connection
+ handle was received from the connection pool. This is also the
+ connection pool version that the database connection handle was
+ created under.
+
+
+
+
+ Removes a connection from the pool of those associated with the
+ specified database file name with the intent of using it to
+ interact with the database.
+
+
+ The database file name.
+
+
+ The new maximum size of the connection pool for the specified
+ database file name.
+
+
+ The connection pool version associated with the returned database
+ connection handle, if any.
+
+
+ The database connection handle associated with the specified
+ database file name or null if it cannot be obtained.
+
+
+
+
+ This default method implementations in this class should not be used by
+ applications that make use of COM (either directly or indirectly) due
+ to possible deadlocks that can occur during finalization of some COM
+ objects.
+
+
+
+
+ Keeps track of connections made on a specified file. The PoolVersion
+ dictates whether old objects get returned to the pool or discarded
+ when no longer in use.
+
+
+
+
+ The queue of weak references to the actual database connection
+ handles.
+
+
+
+
+ This pool version associated with the database connection
+ handles in this pool queue.
+
+
+
+
+ The maximum size of this pool queue.
+
+
+
+
+ Constructs a connection pool queue using the specified version
+ and maximum size. Normally, all the database connection
+ handles in this pool are associated with a single database file
+ name.
+
+
+ The initial pool version for this connection pool queue.
+
+
+ The initial maximum size for this connection pool queue.
+
+
+
+
+ This field is used to synchronize access to the private static data
+ in this class.
+
+
+
+
+ When this field is non-null, it will be used to provide the
+ implementation of all the connection pool methods; otherwise,
+ the default method implementations will be used.
+
+
+
+
+ The dictionary of connection pools, based on the normalized file
+ name of the SQLite database.
+
+
+
+
+ The default version number new pools will get.
+
+
+
+
+ The number of connections successfully opened from any pool.
+ This value is incremented by the Remove method.
+
+
+
+
+ The number of connections successfully closed from any pool.
+ This value is incremented by the Add method.
+
+
+
+
+ Counts the number of pool entries matching the specified file name.
+
+
+ The file name to match or null to match all files.
+
+
+ The pool entry counts for each matching file.
+
+
+ The total number of connections successfully opened from any pool.
+
+
+ The total number of connections successfully closed from any pool.
+
+
+ The total number of pool entries for all matching files.
+
+
+
+
+ Disposes of all pooled connections associated with the specified
+ database file name.
+
+
+ The database file name.
+
+
+
+
+ Disposes of all pooled connections.
+
+
+
+
+ Adds a connection to the pool of those associated with the
+ specified database file name.
+
+
+ The database file name.
+
+
+ The database connection handle.
+
+
+ The connection pool version at the point the database connection
+ handle was received from the connection pool. This is also the
+ connection pool version that the database connection handle was
+ created under.
+
+
+
+
+ Removes a connection from the pool of those associated with the
+ specified database file name with the intent of using it to
+ interact with the database.
+
+
+ The database file name.
+
+
+ The new maximum size of the connection pool for the specified
+ database file name.
+
+
+ The connection pool version associated with the returned database
+ connection handle, if any.
+
+
+ The database connection handle associated with the specified
+ database file name or null if it cannot be obtained.
+
+
+
+
+ This method is used to obtain a reference to the custom connection
+ pool implementation currently in use, if any.
+
+
+ The custom connection pool implementation or null if the default
+ connection pool implementation should be used.
+
+
+
+
+ This method is used to set the reference to the custom connection
+ pool implementation to use, if any.
+
+
+ The custom connection pool implementation to use or null if the
+ default connection pool implementation should be used.
+
+
+
+
+ We do not have to thread-lock anything in this function, because it
+ is only called by other functions above which already take the lock.
+
+
+ The pool queue to resize.
+
+
+ If a function intends to add to the pool, this is true, which
+ forces the resize to take one more than it needs from the pool.
+
+
+
+
+ SQLite implementation of DbConnectionStringBuilder.
+
+
+
+
+ Properties of this class
+
+
+
+
+ Constructs a new instance of the class
+
+
+ Default constructor
+
+
+
+
+ Constructs a new instance of the class using the specified connection string.
+
+ The connection string to parse
+
+
+
+ Private initializer, which assigns the connection string and resets the builder
+
+ The connection string to assign
+
+
+
+ Gets/Sets the default version of the SQLite engine to instantiate. Currently the only valid value is 3, indicating version 3 of the sqlite library.
+
+
+
+
+ Gets/Sets the synchronization mode (file flushing) of the connection string. Default is "Normal".
+
+
+
+
+ Gets/Sets the encoding for the connection string. The default is "False" which indicates UTF-8 encoding.
+
+
+
+
+ Gets/Sets whether or not to use connection pooling. The default is "False"
+
+
+
+
+ Gets/Sets whethor not to store GUID's in binary format. The default is True
+ which saves space in the database.
+
+
+
+
+ Gets/Sets the filename to open on the connection string.
+
+
+
+
+ An alternate to the data source property
+
+
+
+
+ An alternate to the data source property that uses the SQLite URI syntax.
+
+
+
+
+ Gets/sets the default command timeout for newly-created commands. This is especially useful for
+ commands used internally such as inside a SQLiteTransaction, where setting the timeout is not possible.
+
+
+
+
+ Gets/sets the busy timeout to use with the SQLite core library.
+
+
+
+
+ EXPERIMENTAL --
+ The wait timeout to use with
+ method.
+ This is only used when waiting for the enlistment to be reset
+ prior to enlisting in a transaction, and then only when the
+ appropriate connection flag is set.
+
+
+
+
+ Gets/sets the maximum number of retries when preparing SQL to be executed.
+ This normally only applies to preparation errors resulting from the database
+ schema being changed.
+
+
+
+
+ Gets/sets the approximate number of virtual machine instructions between
+ progress events. In order for progress events to actually fire, the event
+ handler must be added to the event
+ as well.
+
+
+
+
+ Determines whether or not the connection will automatically participate
+ in the current distributed transaction (if one exists)
+
+
+
+
+ If set to true, will throw an exception if the database specified in the connection
+ string does not exist. If false, the database will be created automatically.
+
+
+
+
+ If enabled, uses the legacy 3.xx format for maximum compatibility, but results in larger
+ database sizes.
+
+
+
+
+ When enabled, the database will be opened for read-only access and writing will be disabled.
+
+
+
+
+ Gets/sets the database encryption password
+
+
+
+
+ Gets/sets the database encryption hexadecimal password
+
+
+
+
+ Gets/Sets the page size for the connection.
+
+
+
+
+ Gets/Sets the maximum number of pages the database may hold
+
+
+
+
+ Gets/Sets the cache size for the connection.
+
+
+
+
+ Gets/Sets the DateTime format for the connection.
+
+
+
+
+ Gets/Sets the DateTime kind for the connection.
+
+
+
+
+ Gets/sets the DateTime format string used for formatting
+ and parsing purposes.
+
+
+
+
+ Gets/Sets the placeholder base schema name used for
+ .NET Framework compatibility purposes.
+
+
+
+
+ Determines how SQLite handles the transaction journal file.
+
+
+
+
+ Sets the default isolation level for transactions on the connection.
+
+
+
+
+ Gets/sets the default database type for the connection.
+
+
+
+
+ Gets/sets the default type name for the connection.
+
+
+
+
+ Gets/sets the VFS name for the connection.
+
+
+
+
+ If enabled, use foreign key constraints
+
+
+
+
+ Enable or disable the recursive trigger capability.
+
+
+
+
+ If non-null, this is the version of ZipVFS to use. This requires the
+ System.Data.SQLite interop assembly -AND- primary managed assembly to
+ be compiled with the INTEROP_INCLUDE_ZIPVFS option; otherwise, this
+ property does nothing.
+
+
+
+
+ Gets/Sets the extra behavioral flags.
+
+
+
+
+ If enabled, apply the default connection settings to opened databases.
+
+
+
+
+ If enabled, attempt to resolve the provided data source file name to a
+ full path before opening.
+
+
+
+
+ If enabled, skip using the configured default connection flags.
+
+
+
+
+ If enabled, skip using the configured shared connection flags.
+
+
+
+
+ Helper function for retrieving values from the connectionstring
+
+ The keyword to retrieve settings for
+ The resulting parameter value
+ Returns true if the value was found and returned
+
+
+
+ Fallback method for MONO, which doesn't implement DbConnectionStringBuilder.GetProperties()
+
+ The hashtable to fill with property descriptors
+
+
+
+ This base class provides datatype conversion services for the SQLite provider.
+
+
+
+
+ This character is used to escape other characters, including itself, in
+ connection string property names and values.
+
+
+
+
+ This character can be used to wrap connection string property names and
+ values. Normally, it is optional; however, when used, it must be the
+ first -AND- last character of that connection string property name -OR-
+ value.
+
+
+
+
+ This character can be used to wrap connection string property names and
+ values. Normally, it is optional; however, when used, it must be the
+ first -AND- last character of that connection string property name -OR-
+ value.
+
+
+
+
+ The character is used to separate the name and value for a connection
+ string property. This character cannot be present in any connection
+ string property name. This character can be present in a connection
+ string property value; however, this should be avoided unless deemed
+ absolutely necessary.
+
+
+
+
+ This character is used to separate connection string properties. When
+ the "No_SQLiteConnectionNewParser" setting is enabled, this character
+ may not appear in connection string property names -OR- values.
+
+
+
+
+ These are the characters that are special to the connection string
+ parser.
+
+
+
+
+ The fallback default database type when one cannot be obtained from an
+ existing connection instance.
+
+
+
+
+ The fallback default database type name when one cannot be obtained from
+ an existing connection instance.
+
+
+
+
+ The value for the Unix epoch (e.g. January 1, 1970 at midnight, in UTC).
+
+
+
+
+ The value of the OLE Automation epoch represented as a Julian day. This
+ field cannot be removed as the test suite relies upon it.
+
+
+
+
+ The format string for DateTime values when using the InvariantCulture or CurrentCulture formats.
+
+
+
+
+ This is the minimum Julian Day value supported by this library
+ (148731163200000).
+
+
+
+
+ This is the maximum Julian Day value supported by this library
+ (464269060799000).
+
+
+
+
+ An array of ISO-8601 DateTime formats that we support parsing.
+
+
+
+
+ The internal default format for UTC DateTime values when converting
+ to a string.
+
+
+
+
+ The internal default format for local DateTime values when converting
+ to a string.
+
+
+
+
+ An UTF-8 Encoding instance, so we can convert strings to and from UTF-8
+
+
+
+
+ The default DateTime format for this instance.
+
+
+
+
+ The default DateTimeKind for this instance.
+
+
+
+
+ The default DateTime format string for this instance.
+
+
+
+
+ Initializes the conversion class
+
+ The default date/time format to use for this instance
+ The DateTimeKind to use.
+ The DateTime format string to use.
+
+
+
+ Converts a string to a UTF-8 encoded byte array sized to include a null-terminating character.
+
+ The string to convert to UTF-8
+ A byte array containing the converted string plus an extra 0 terminating byte at the end of the array.
+
+
+
+ Convert a DateTime to a UTF-8 encoded, zero-terminated byte array.
+
+
+ This function is a convenience function, which first calls ToString() on the DateTime, and then calls ToUTF8() with the
+ string result.
+
+ The DateTime to convert.
+ The UTF-8 encoded string, including a 0 terminating byte at the end of the array.
+
+
+
+ Converts a UTF-8 encoded IntPtr of the specified length into a .NET string
+
+ The pointer to the memory where the UTF-8 string is encoded
+ The number of bytes to decode
+ A string containing the translated character(s)
+
+
+
+ Converts a UTF-8 encoded IntPtr of the specified length into a .NET string
+
+ The pointer to the memory where the UTF-8 string is encoded
+ The number of bytes to decode
+ A string containing the translated character(s)
+
+
+
+ Checks if the specified is within the
+ supported range for a Julian Day value.
+
+
+ The Julian Day value to check.
+
+
+ Non-zero if the specified Julian Day value is in the supported
+ range; otherwise, zero.
+
+
+
+
+ Converts a Julian Day value from a to an
+ .
+
+
+ The Julian Day value to convert.
+
+
+ The resulting Julian Day value.
+
+
+
+
+ Converts a Julian Day value from an to a
+ .
+
+
+ The Julian Day value to convert.
+
+
+ The resulting Julian Day value.
+
+
+
+
+ Converts a Julian Day value to a .
+ This method was translated from the "computeYMD" function in the
+ "date.c" file belonging to the SQLite core library.
+
+
+ The Julian Day value to convert.
+
+
+ The value to return in the event that the
+ Julian Day is out of the supported range. If this value is null,
+ an exception will be thrown instead.
+
+
+ A value that contains the year, month, and
+ day values that are closest to the specified Julian Day value.
+
+
+
+
+ Converts a Julian Day value to a .
+ This method was translated from the "computeHMS" function in the
+ "date.c" file belonging to the SQLite core library.
+
+
+ The Julian Day value to convert.
+
+
+ The value to return in the event that the
+ Julian Day value is out of the supported range. If this value is
+ null, an exception will be thrown instead.
+
+
+ A value that contains the hour, minute, and
+ second, and millisecond values that are closest to the specified
+ Julian Day value.
+
+
+
+
+ Converts a to a Julian Day value.
+ This method was translated from the "computeJD" function in
+ the "date.c" file belonging to the SQLite core library.
+ Since the range of Julian Day values supported by this method
+ includes all possible (valid) values of a
+ value, it should be extremely difficult for this method to
+ raise an exception or return an undefined result.
+
+
+ The value to convert. This value
+ will be within the range of
+ (00:00:00.0000000, January 1, 0001) to
+ (23:59:59.9999999, December
+ 31, 9999).
+
+
+ The nearest Julian Day value corresponding to the specified
+ value.
+
+
+
+
+ Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind,
+ and DateTimeFormatString specified for the connection when it was opened.
+
+
+ Acceptable ISO8601 DateTime formats are:
+
+ THHmmssK
+ THHmmK
+ HH:mm:ss.FFFFFFFK
+ HH:mm:ssK
+ HH:mmK
+ yyyy-MM-dd HH:mm:ss.FFFFFFFK
+ yyyy-MM-dd HH:mm:ssK
+ yyyy-MM-dd HH:mmK
+ yyyy-MM-ddTHH:mm:ss.FFFFFFFK
+ yyyy-MM-ddTHH:mmK
+ yyyy-MM-ddTHH:mm:ssK
+ yyyyMMddHHmmssK
+ yyyyMMddHHmmK
+ yyyyMMddTHHmmssFFFFFFFK
+ THHmmss
+ THHmm
+ HH:mm:ss.FFFFFFF
+ HH:mm:ss
+ HH:mm
+ yyyy-MM-dd HH:mm:ss.FFFFFFF
+ yyyy-MM-dd HH:mm:ss
+ yyyy-MM-dd HH:mm
+ yyyy-MM-ddTHH:mm:ss.FFFFFFF
+ yyyy-MM-ddTHH:mm
+ yyyy-MM-ddTHH:mm:ss
+ yyyyMMddHHmmss
+ yyyyMMddHHmm
+ yyyyMMddTHHmmssFFFFFFF
+ yyyy-MM-dd
+ yyyyMMdd
+ yy-MM-dd
+
+ If the string cannot be matched to one of the above formats -OR-
+ the DateTimeFormatString if one was provided, an exception will
+ be thrown.
+
+ The string containing either a long integer number of 100-nanosecond units since
+ System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a
+ culture-independent formatted date and time string, a formatted date and time string in the current
+ culture, or an ISO8601-format string.
+ A DateTime value
+
+
+
+ Converts a string into a DateTime, using the specified DateTimeFormat,
+ DateTimeKind and DateTimeFormatString.
+
+
+ Acceptable ISO8601 DateTime formats are:
+
+ THHmmssK
+ THHmmK
+ HH:mm:ss.FFFFFFFK
+ HH:mm:ssK
+ HH:mmK
+ yyyy-MM-dd HH:mm:ss.FFFFFFFK
+ yyyy-MM-dd HH:mm:ssK
+ yyyy-MM-dd HH:mmK
+ yyyy-MM-ddTHH:mm:ss.FFFFFFFK
+ yyyy-MM-ddTHH:mmK
+ yyyy-MM-ddTHH:mm:ssK
+ yyyyMMddHHmmssK
+ yyyyMMddHHmmK
+ yyyyMMddTHHmmssFFFFFFFK
+ THHmmss
+ THHmm
+ HH:mm:ss.FFFFFFF
+ HH:mm:ss
+ HH:mm
+ yyyy-MM-dd HH:mm:ss.FFFFFFF
+ yyyy-MM-dd HH:mm:ss
+ yyyy-MM-dd HH:mm
+ yyyy-MM-ddTHH:mm:ss.FFFFFFF
+ yyyy-MM-ddTHH:mm
+ yyyy-MM-ddTHH:mm:ss
+ yyyyMMddHHmmss
+ yyyyMMddHHmm
+ yyyyMMddTHHmmssFFFFFFF
+ yyyy-MM-dd
+ yyyyMMdd
+ yy-MM-dd
+
+ If the string cannot be matched to one of the above formats -OR-
+ the DateTimeFormatString if one was provided, an exception will
+ be thrown.
+
+ The string containing either a long integer number of 100-nanosecond units since
+ System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a
+ culture-independent formatted date and time string, a formatted date and time string in the current
+ culture, or an ISO8601-format string.
+ The SQLiteDateFormats to use.
+ The DateTimeKind to use.
+ The DateTime format string to use.
+ A DateTime value
+
+
+
+ Converts a julianday value into a DateTime
+
+ The value to convert
+ A .NET DateTime
+
+
+
+ Converts a julianday value into a DateTime
+
+ The value to convert
+ The DateTimeKind to use.
+ A .NET DateTime
+
+
+
+ Converts the specified number of seconds from the Unix epoch into a
+ value.
+
+
+ The number of whole seconds since the Unix epoch.
+
+
+ Either Utc or Local time.
+
+
+ The new value.
+
+
+
+
+ Converts the specified number of ticks since the epoch into a
+ value.
+
+
+ The number of whole ticks since the epoch.
+
+
+ Either Utc or Local time.
+
+
+ The new value.
+
+
+
+
+ Converts a DateTime struct to a JulianDay double
+
+ The DateTime to convert
+ The JulianDay value the Datetime represents
+
+
+
+ Converts a DateTime struct to the whole number of seconds since the
+ Unix epoch.
+
+ The DateTime to convert
+ The whole number of seconds since the Unix epoch
+
+
+
+ Returns the DateTime format string to use for the specified DateTimeKind.
+ If is not null, it will be returned verbatim.
+
+ The DateTimeKind to use.
+ The DateTime format string to use.
+
+ The DateTime format string to use for the specified DateTimeKind.
+
+
+
+
+ Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind,
+ and DateTimeFormatString specified for the connection when it was opened.
+
+ The DateTime value to convert
+ Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a
+ Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time
+ string, a formatted date and time string in the current culture, or an ISO8601-format date/time string.
+
+
+
+ Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind,
+ and DateTimeFormatString specified for the connection when it was opened.
+
+ The DateTime value to convert
+ The SQLiteDateFormats to use.
+ The DateTimeKind to use.
+ The DateTime format string to use.
+ Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a
+ Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time
+ string, a formatted date and time string in the current culture, or an ISO8601-format date/time string.
+
+
+
+ Internal function to convert a UTF-8 encoded IntPtr of the specified length to a DateTime.
+
+
+ This is a convenience function, which first calls ToString() on the IntPtr to convert it to a string, then calls
+ ToDateTime() on the string to return a DateTime.
+
+ A pointer to the UTF-8 encoded string
+ The length in bytes of the string
+ The parsed DateTime value
+
+
+
+ Smart method of splitting a string. Skips quoted elements, removes the quotes.
+
+
+ This split function works somewhat like the String.Split() function in that it breaks apart a string into
+ pieces and returns the pieces as an array. The primary differences are:
+
+ Only one character can be provided as a separator character
+ Quoted text inside the string is skipped over when searching for the separator, and the quotes are removed.
+
+ Thus, if splitting the following string looking for a comma:
+ One,Two, "Three, Four", Five
+
+ The resulting array would contain
+ [0] One
+ [1] Two
+ [2] Three, Four
+ [3] Five
+
+ Note that the leading and trailing spaces were removed from each item during the split.
+
+ Source string to split apart
+ Separator character
+ A string array of the split up elements
+
+
+
+ Splits the specified string into multiple strings based on a separator
+ and returns the result as an array of strings.
+
+
+ The string to split into pieces based on the separator character. If
+ this string is null, null will always be returned. If this string is
+ empty, an array of zero strings will always be returned.
+
+
+ The character used to divide the original string into sub-strings.
+ This character cannot be a backslash or a double-quote; otherwise, no
+ work will be performed and null will be returned.
+
+
+ If this parameter is non-zero, all double-quote characters will be
+ retained in the returned list of strings; otherwise, they will be
+ dropped.
+
+
+ Upon failure, this parameter will be modified to contain an appropriate
+ error message.
+
+
+ The new array of strings or null if the input string is null -OR- the
+ separator character is a backslash or a double-quote -OR- the string
+ contains an unbalanced backslash or double-quote character.
+
+
+
+
+ Queries and returns the string representation for an object, using the
+ specified (or current) format provider.
+
+
+ The object instance to return the string representation for.
+
+
+ The format provider to use -OR- null if the current format provider for
+ the thread should be used instead.
+
+
+ The string representation for the object instance -OR- null if the
+ object instance is also null.
+
+
+
+
+ Attempts to convert an arbitrary object to the Boolean data type.
+ Null object values are converted to false. Throws an exception
+ upon failure.
+
+
+ The object value to convert.
+
+
+ The format provider to use.
+
+
+ If non-zero, a string value will be converted using the
+
+ method; otherwise, the
+ method will be used.
+
+
+ The converted boolean value.
+
+
+
+
+ Convert a value to true or false.
+
+ A string or number representing true or false
+
+
+
+
+ Converts an integer to a string that can be round-tripped using the
+ invariant culture.
+
+
+ The integer value to return the string representation for.
+
+
+ The string representation of the specified integer value, using the
+ invariant culture.
+
+
+
+
+ Attempts to convert a into a .
+
+
+ The to convert, cannot be null.
+
+
+ The converted value.
+
+
+ The supported strings are "yes", "no", "y", "n", "on", "off", "0", "1",
+ as well as any prefix of the strings
+ and . All strings are treated in a
+ case-insensitive manner.
+
+
+
+
+ Converts a SQLiteType to a .NET Type object
+
+ The SQLiteType to convert
+ Returns a .NET Type object
+
+
+
+ For a given intrinsic type, return a DbType
+
+ The native type to convert
+ The corresponding (closest match) DbType
+
+
+
+ Returns the ColumnSize for the given DbType
+
+ The DbType to get the size of
+
+
+
+
+ Determines the default database type name to be used when a
+ per-connection value is not available.
+
+
+ The connection context for type mappings, if any.
+
+
+ The default database type name to use.
+
+
+
+
+ If applicable, issues a trace log message warning about falling back to
+ the default database type name.
+
+
+ The database value type.
+
+
+ The flags associated with the parent connection object.
+
+
+ The textual name of the database type.
+
+
+
+
+ If applicable, issues a trace log message warning about falling back to
+ the default database value type.
+
+
+ The textual name of the database type.
+
+
+ The flags associated with the parent connection object.
+
+
+ The database value type.
+
+
+
+
+ For a given database value type, return the "closest-match" textual database type name.
+
+ The connection context for custom type mappings, if any.
+ The database value type.
+ The flags associated with the parent connection object.
+ The type name or an empty string if it cannot be determined.
+
+
+
+ Convert a DbType to a Type
+
+ The DbType to convert from
+ The closest-match .NET type
+
+
+
+ For a given type, return the closest-match SQLite TypeAffinity, which only understands a very limited subset of types.
+
+ The type to evaluate
+ The flags associated with the connection.
+ The SQLite type affinity for that type.
+
+
+
+ Builds and returns a map containing the database column types
+ recognized by this provider.
+
+
+ A map containing the database column types recognized by this
+ provider.
+
+
+
+
+ Determines if a database type is considered to be a string.
+
+
+ The database type to check.
+
+
+ Non-zero if the database type is considered to be a string, zero
+ otherwise.
+
+
+
+
+ Determines and returns the runtime configuration setting string that
+ should be used in place of the specified object value.
+
+
+ The object value to convert to a string.
+
+
+ Either the string to use in place of the object value -OR- null if it
+ cannot be determined.
+
+
+
+
+ Determines the default value to be used when a
+ per-connection value is not available.
+
+
+ The connection context for type mappings, if any.
+
+
+ The default value to use.
+
+
+
+
+ Converts the object value, which is assumed to have originated
+ from a , to a string value.
+
+
+ The value to be converted to a string.
+
+
+ A null value will be returned if the original value is null -OR-
+ the original value is . Otherwise,
+ the original value will be converted to a string, using its
+ (possibly overridden) method and
+ then returned.
+
+
+
+
+ Determines if the specified textual value appears to be a
+ value.
+
+
+ The textual value to inspect.
+
+
+ Non-zero if the text looks like a value,
+ zero otherwise.
+
+
+
+
+ Determines if the specified textual value appears to be an
+ value.
+
+
+ The textual value to inspect.
+
+
+ Non-zero if the text looks like an value,
+ zero otherwise.
+
+
+
+
+ Determines if the specified textual value appears to be a
+ value.
+
+
+ The textual value to inspect.
+
+
+ Non-zero if the text looks like a value,
+ zero otherwise.
+
+
+
+
+ Determines if the specified textual value appears to be a
+ value.
+
+
+ The object instance configured with
+ the chosen format.
+
+
+ The textual value to inspect.
+
+
+ Non-zero if the text looks like a in the
+ configured format, zero otherwise.
+
+
+
+
+ For a given textual database type name, return the "closest-match" database type.
+ This method is called during query result processing; therefore, its performance
+ is critical.
+
+ The connection context for custom type mappings, if any.
+ The textual name of the database type to match.
+ The flags associated with the parent connection object.
+ The .NET DBType the text evaluates to.
+
+
+
+ SQLite has very limited types, and is inherently text-based. The first 5 types below represent the sum of all types SQLite
+ understands. The DateTime extension to the spec is for internal use only.
+
+
+
+
+ Not used
+
+
+
+
+ All integers in SQLite default to Int64
+
+
+
+
+ All floating point numbers in SQLite default to double
+
+
+
+
+ The default data type of SQLite is text
+
+
+
+
+ Typically blob types are only seen when returned from a function
+
+
+
+
+ Null types can be returned from functions
+
+
+
+
+ Used internally by this provider
+
+
+
+
+ Used internally by this provider
+
+
+
+
+ These are the event types associated with the
+
+ delegate (and its corresponding event) and the
+ class.
+
+
+
+
+ Not used.
+
+
+
+
+ Not used.
+
+
+
+
+ The connection is being opened.
+
+
+
+
+ The connection string has been parsed.
+
+
+
+
+ The connection was opened.
+
+
+
+
+ The method was called on the
+ connection.
+
+
+
+
+ A transaction was created using the connection.
+
+
+
+
+ The connection was enlisted into a transaction.
+
+
+
+
+ A command was created using the connection.
+
+
+
+
+ A data reader was created using the connection.
+
+
+
+
+ An instance of a derived class has
+ been created to wrap a native resource.
+
+
+
+
+ The connection is being closed.
+
+
+
+
+ The connection was closed.
+
+
+
+
+ A command is being disposed.
+
+
+
+
+ A data reader is being disposed.
+
+
+
+
+ A data reader is being closed.
+
+
+
+
+ A native resource was opened (i.e. obtained) from the pool.
+
+
+
+
+ A native resource was closed (i.e. released) to the pool.
+
+
+
+
+ This implementation of SQLite for ADO.NET can process date/time fields in
+ databases in one of six formats.
+
+
+ ISO8601 format is more compatible, readable, fully-processable, but less
+ accurate as it does not provide time down to fractions of a second.
+ JulianDay is the numeric format the SQLite uses internally and is arguably
+ the most compatible with 3rd party tools. It is not readable as text
+ without post-processing. Ticks less compatible with 3rd party tools that
+ query the database, and renders the DateTime field unreadable as text
+ without post-processing. UnixEpoch is more compatible with Unix systems.
+ InvariantCulture allows the configured format for the invariant culture
+ format to be used and is human readable. CurrentCulture allows the
+ configured format for the current culture to be used and is also human
+ readable.
+
+ The preferred order of choosing a DateTime format is JulianDay, ISO8601,
+ and then Ticks. Ticks is mainly present for legacy code support.
+
+
+
+
+ Use the value of DateTime.Ticks. This value is not recommended and is not well supported with LINQ.
+
+
+
+
+ Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC DateTime values and
+ "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values).
+
+
+
+
+ The interval of time in days and fractions of a day since January 1, 4713 BC.
+
+
+
+
+ The whole number of seconds since the Unix epoch (January 1, 1970).
+
+
+
+
+ Any culture-independent string value that the .NET Framework can interpret as a valid DateTime.
+
+
+
+
+ Any string value that the .NET Framework can interpret as a valid DateTime using the current culture.
+
+
+
+
+ The default format for this provider.
+
+
+
+
+ This enum determines how SQLite treats its journal file.
+
+
+ By default SQLite will create and delete the journal file when needed during a transaction.
+ However, for some computers running certain filesystem monitoring tools, the rapid
+ creation and deletion of the journal file can cause those programs to fail, or to interfere with SQLite.
+
+ If a program or virus scanner is interfering with SQLite's journal file, you may receive errors like "unable to open database file"
+ when starting a transaction. If this is happening, you may want to change the default journal mode to Persist.
+
+
+
+
+ The default mode, this causes SQLite to use the existing journaling mode for the database.
+
+
+
+
+ SQLite will create and destroy the journal file as-needed.
+
+
+
+
+ When this is set, SQLite will keep the journal file even after a transaction has completed. It's contents will be erased,
+ and the journal re-used as often as needed. If it is deleted, it will be recreated the next time it is needed.
+
+
+
+
+ This option disables the rollback journal entirely. Interrupted transactions or a program crash can cause database
+ corruption in this mode!
+
+
+
+
+ SQLite will truncate the journal file to zero-length instead of deleting it.
+
+
+
+
+ SQLite will store the journal in volatile RAM. This saves disk I/O but at the expense of database safety and integrity.
+ If the application using SQLite crashes in the middle of a transaction when the MEMORY journaling mode is set, then the
+ database file will very likely go corrupt.
+
+
+
+
+ SQLite uses a write-ahead log instead of a rollback journal to implement transactions. The WAL journaling mode is persistent;
+ after being set it stays in effect across multiple database connections and after closing and reopening the database. A database
+ in WAL journaling mode can only be accessed by SQLite version 3.7.0 or later.
+
+
+
+
+ Possible values for the "synchronous" database setting. This setting determines
+ how often the database engine calls the xSync method of the VFS.
+
+
+
+
+ Use the default "synchronous" database setting. Currently, this should be
+ the same as using the FULL mode.
+
+
+
+
+ The database engine continues without syncing as soon as it has handed
+ data off to the operating system. If the application running SQLite
+ crashes, the data will be safe, but the database might become corrupted
+ if the operating system crashes or the computer loses power before that
+ data has been written to the disk surface.
+
+
+
+
+ The database engine will still sync at the most critical moments, but
+ less often than in FULL mode. There is a very small (though non-zero)
+ chance that a power failure at just the wrong time could corrupt the
+ database in NORMAL mode.
+
+
+
+
+ The database engine will use the xSync method of the VFS to ensure that
+ all content is safely written to the disk surface prior to continuing.
+ This ensures that an operating system crash or power failure will not
+ corrupt the database. FULL synchronous is very safe, but it is also
+ slower.
+
+
+
+
+ The requested command execution type. This controls which method of the
+ object will be called.
+
+
+
+
+ Do nothing. No method will be called.
+
+
+
+
+ The command is not expected to return a result -OR- the result is not
+ needed. The or
+ method
+ will be called.
+
+
+
+
+ The command is expected to return a scalar result -OR- the result should
+ be limited to a scalar result. The
+ or method will
+ be called.
+
+
+
+
+ The command is expected to return result.
+ The or
+ method will
+ be called.
+
+
+
+
+ Use the default command execution type. Using this value is the same
+ as using the value.
+
+
+
+
+ The action code responsible for the current call into the authorizer.
+
+
+
+
+ No action is being performed. This value should not be used from
+ external code.
+
+
+
+
+ No longer used.
+
+
+
+
+ An index will be created. The action-specific arguments are the
+ index name and the table name.
+
+
+
+
+
+ A table will be created. The action-specific arguments are the
+ table name and a null value.
+
+
+
+
+ A temporary index will be created. The action-specific arguments
+ are the index name and the table name.
+
+
+
+
+ A temporary table will be created. The action-specific arguments
+ are the table name and a null value.
+
+
+
+
+ A temporary trigger will be created. The action-specific arguments
+ are the trigger name and the table name.
+
+
+
+
+ A temporary view will be created. The action-specific arguments are
+ the view name and a null value.
+
+
+
+
+ A trigger will be created. The action-specific arguments are the
+ trigger name and the table name.
+
+
+
+
+ A view will be created. The action-specific arguments are the view
+ name and a null value.
+
+
+
+
+ A DELETE statement will be executed. The action-specific arguments
+ are the table name and a null value.
+
+
+
+
+ An index will be dropped. The action-specific arguments are the
+ index name and the table name.
+
+
+
+
+ A table will be dropped. The action-specific arguments are the tables
+ name and a null value.
+
+
+
+
+ A temporary index will be dropped. The action-specific arguments are
+ the index name and the table name.
+
+
+
+
+ A temporary table will be dropped. The action-specific arguments are
+ the table name and a null value.
+
+
+
+
+ A temporary trigger will be dropped. The action-specific arguments
+ are the trigger name and the table name.
+
+
+
+
+ A temporary view will be dropped. The action-specific arguments are
+ the view name and a null value.
+
+
+
+
+ A trigger will be dropped. The action-specific arguments are the
+ trigger name and the table name.
+
+
+
+
+ A view will be dropped. The action-specific arguments are the view
+ name and a null value.
+
+
+
+
+ An INSERT statement will be executed. The action-specific arguments
+ are the table name and a null value.
+
+
+
+
+ A PRAGMA statement will be executed. The action-specific arguments
+ are the name of the PRAGMA and the new value or a null value.
+
+
+
+
+ A table column will be read. The action-specific arguments are the
+ table name and the column name.
+
+
+
+
+ A SELECT statement will be executed. The action-specific arguments
+ are both null values.
+
+
+
+
+ A transaction will be started, committed, or rolled back. The
+ action-specific arguments are the name of the operation (BEGIN,
+ COMMIT, or ROLLBACK) and a null value.
+
+
+
+
+ An UPDATE statement will be executed. The action-specific arguments
+ are the table name and the column name.
+
+
+
+
+ A database will be attached to the connection. The action-specific
+ arguments are the database file name and a null value.
+
+
+
+
+ A database will be detached from the connection. The action-specific
+ arguments are the database name and a null value.
+
+
+
+
+ The schema of a table will be altered. The action-specific arguments
+ are the database name and the table name.
+
+
+
+
+ An index will be deleted and then recreated. The action-specific
+ arguments are the index name and a null value.
+
+
+
+
+ A table will be analyzed to gathers statistics about it. The
+ action-specific arguments are the table name and a null value.
+
+
+
+
+ A virtual table will be created. The action-specific arguments are
+ the table name and the module name.
+
+
+
+
+ A virtual table will be dropped. The action-specific arguments are
+ the table name and the module name.
+
+
+
+
+ A SQL function will be called. The action-specific arguments are a
+ null value and the function name.
+
+
+
+
+ A savepoint will be created, released, or rolled back. The
+ action-specific arguments are the name of the operation (BEGIN,
+ RELEASE, or ROLLBACK) and the savepoint name.
+
+
+
+
+ A recursive query will be executed. The action-specific arguments
+ are two null values.
+
+
+
+
+ The possible return codes for the progress callback.
+
+
+
+
+ The operation should continue.
+
+
+
+
+ The operation should be interrupted.
+
+
+
+
+ The return code for the current call into the authorizer.
+
+
+
+
+ The action will be allowed.
+
+
+
+
+ The overall action will be disallowed and an error message will be
+ returned from the query preparation method.
+
+
+
+
+ The specific action will be disallowed; however, the overall action
+ will continue. The exact effects of this return code vary depending
+ on the specific action, please refer to the SQLite core library
+ documentation for futher details.
+
+
+
+
+ Class used internally to determine the datatype of a column in a resultset
+
+
+
+
+ The DbType of the column, or DbType.Object if it cannot be determined
+
+
+
+
+ The affinity of a column, used for expressions or when Type is DbType.Object
+
+
+
+
+ Constructs a default instance of this type.
+
+
+
+
+ Constructs an instance of this type with the specified field values.
+
+
+ The type affinity to use for the new instance.
+
+
+ The database type to use for the new instance.
+
+
+
+
+ SQLite implementation of DbDataAdapter.
+
+
+
+
+ This class is just a shell around the DbDataAdapter. Nothing from
+ DbDataAdapter is overridden here, just a few constructors are defined.
+
+
+ Default constructor.
+
+
+
+
+ Constructs a data adapter using the specified select command.
+
+
+ The select command to associate with the adapter.
+
+
+
+
+ Constructs a data adapter with the supplied select command text and
+ associated with the specified connection.
+
+
+ The select command text to associate with the data adapter.
+
+
+ The connection to associate with the select command.
+
+
+
+
+ Constructs a data adapter with the specified select command text,
+ and using the specified database connection string.
+
+
+ The select command text to use to construct a select command.
+
+
+ A connection string suitable for passing to a new SQLiteConnection,
+ which is associated with the select command.
+
+
+
+
+ Constructs a data adapter with the specified select command text,
+ and using the specified database connection string.
+
+
+ The select command text to use to construct a select command.
+
+
+ A connection string suitable for passing to a new SQLiteConnection,
+ which is associated with the select command.
+
+
+ Non-zero to parse the connection string using the built-in (i.e.
+ framework provided) parser when opening the connection.
+
+
+
+
+ Cleans up resources (native and managed) associated with the current instance.
+
+
+ Zero when being disposed via garbage collection; otherwise, non-zero.
+
+
+
+
+ Row updating event handler
+
+
+
+
+ Row updated event handler
+
+
+
+
+ Raised by the underlying DbDataAdapter when a row is being updated
+
+ The event's specifics
+
+
+
+ Raised by DbDataAdapter after a row is updated
+
+ The event's specifics
+
+
+
+ Gets/sets the select command for this DataAdapter
+
+
+
+
+ Gets/sets the insert command for this DataAdapter
+
+
+
+
+ Gets/sets the update command for this DataAdapter
+
+
+
+
+ Gets/sets the delete command for this DataAdapter
+
+
+
+
+ SQLite implementation of DbDataReader.
+
+
+
+
+ Underlying command this reader is attached to
+
+
+
+
+ The flags pertaining to the associated connection (via the command).
+
+
+
+
+ Index of the current statement in the command being processed
+
+
+
+
+ Current statement being Read()
+
+
+
+
+ State of the current statement being processed.
+ -1 = First Step() executed, so the first Read() will be ignored
+ 0 = Actively reading
+ 1 = Finished reading
+ 2 = Non-row-returning statement, no records
+
+
+
+
+ Number of records affected by the insert/update statements executed on the command
+
+
+
+
+ Count of fields (columns) in the row-returning statement currently being processed
+
+
+
+
+ The number of calls to Step() that have returned true (i.e. the number of rows that
+ have been read in the current result set).
+
+
+
+
+ Maps the field (column) names to their corresponding indexes within the results.
+
+
+
+
+ Datatypes of active fields (columns) in the current statement, used for type-restricting data
+
+
+
+
+ The behavior of the datareader
+
+
+
+
+ If set, then dispose of the command object when the reader is finished
+
+
+
+
+ If set, then raise an exception when the object is accessed after being disposed.
+
+
+
+
+ An array of rowid's for the active statement if CommandBehavior.KeyInfo is specified
+
+
+
+
+ Matches the version of the connection.
+
+
+
+
+ The "stub" (i.e. placeholder) base schema name to use when returning
+ column schema information. Matches the base schema name used by the
+ associated connection.
+
+
+
+
+ Internal constructor, initializes the datareader and sets up to begin executing statements
+
+ The SQLiteCommand this data reader is for
+ The expected behavior of the data reader
+
+
+
+ Dispose of all resources used by this datareader.
+
+
+
+
+
+ Closes the datareader, potentially closing the connection as well if CommandBehavior.CloseConnection was specified.
+
+
+
+
+ Throw an error if the datareader is closed
+
+
+
+
+ Throw an error if a row is not loaded
+
+
+
+
+ Enumerator support
+
+ Returns a DbEnumerator object.
+
+
+
+ Not implemented. Returns 0
+
+
+
+
+ Returns the number of columns in the current resultset
+
+
+
+
+ Forces the connection flags cached by this data reader to be refreshed
+ from the underlying connection.
+
+
+
+
+ Returns the number of rows seen so far in the current result set.
+
+
+
+
+ Returns the number of visible fields in the current resultset
+
+
+
+
+ This method is used to make sure the result set is open and a row is currently available.
+
+
+
+
+ SQLite is inherently un-typed. All datatypes in SQLite are natively strings. The definition of the columns of a table
+ and the affinity of returned types are all we have to go on to type-restrict data in the reader.
+
+ This function attempts to verify that the type of data being requested of a column matches the datatype of the column. In
+ the case of columns that are not backed into a table definition, we attempt to match up the affinity of a column (int, double, string or blob)
+ to a set of known types that closely match that affinity. It's not an exact science, but its the best we can do.
+
+
+ This function throws an InvalidTypeCast() exception if the requested type doesn't match the column's definition or affinity.
+
+ The index of the column to type-check
+ The type we want to get out of the column
+
+
+
+ Invokes the data reader value callback configured for the database
+ type name associated with the specified column. If no data reader
+ value callback is available for the database type name, do nothing.
+
+
+ The index of the column being read.
+
+
+ The extra event data to pass into the callback.
+
+
+ Non-zero if the default handling for the data reader call should be
+ skipped. If this is set to non-zero and the necessary return value
+ is unavailable or unsuitable, an exception will be thrown.
+
+
+
+
+ Attempts to query the integer identifier for the current row. This
+ will not work for tables that were created WITHOUT ROWID -OR- if the
+ query does not include the "rowid" column or one of its aliases -OR-
+ if the was not created with the
+ flag.
+
+
+ The index of the BLOB column.
+
+
+ The integer identifier for the current row -OR- null if it could not
+ be determined.
+
+
+
+
+ Retrieves the column as a object.
+ This will not work for tables that were created WITHOUT ROWID
+ -OR- if the query does not include the "rowid" column or one
+ of its aliases -OR- if the was
+ not created with the
+ flag.
+
+ The index of the column.
+
+ Non-zero to open the blob object for read-only access.
+
+ A new object.
+
+
+
+ Retrieves the column as a boolean value
+
+ The index of the column.
+ bool
+
+
+
+ Retrieves the column as a single byte value
+
+ The index of the column.
+ byte
+
+
+
+ Retrieves a column as an array of bytes (blob)
+
+ The index of the column.
+ The zero-based index of where to begin reading the data
+ The buffer to write the bytes into
+ The zero-based index of where to begin writing into the array
+ The number of bytes to retrieve
+ The actual number of bytes written into the array
+
+ To determine the number of bytes in the column, pass a null value for the buffer. The total length will be returned.
+
+
+
+
+ Returns the column as a single character
+
+ The index of the column.
+ char
+
+
+
+ Retrieves a column as an array of chars (blob)
+
+ The index of the column.
+ The zero-based index of where to begin reading the data
+ The buffer to write the characters into
+ The zero-based index of where to begin writing into the array
+ The number of bytes to retrieve
+ The actual number of characters written into the array
+
+ To determine the number of characters in the column, pass a null value for the buffer. The total length will be returned.
+
+
+
+
+ Retrieves the name of the back-end datatype of the column
+
+ The index of the column.
+ string
+
+
+
+ Retrieve the column as a date/time value
+
+ The index of the column.
+ DateTime
+
+
+
+ Retrieve the column as a decimal value
+
+ The index of the column.
+ decimal
+
+
+
+ Returns the column as a double
+
+ The index of the column.
+ double
+
+
+
+ Determines and returns the of the
+ specified column.
+
+
+ The index of the column.
+
+
+ The associated with the specified
+ column, if any.
+
+
+
+
+ Returns the .NET type of a given column
+
+ The index of the column.
+ Type
+
+
+
+ Returns a column as a float value
+
+ The index of the column.
+ float
+
+
+
+ Returns the column as a Guid
+
+ The index of the column.
+ Guid
+
+
+
+ Returns the column as a short
+
+ The index of the column.
+ Int16
+
+
+
+ Retrieves the column as an int
+
+ The index of the column.
+ Int32
+
+
+
+ Retrieves the column as a long
+
+ The index of the column.
+ Int64
+
+
+
+ Retrieves the name of the column
+
+ The index of the column.
+ string
+
+
+
+ Returns the name of the database associated with the specified column.
+
+ The index of the column.
+ string
+
+
+
+ Returns the name of the table associated with the specified column.
+
+ The index of the column.
+ string
+
+
+
+ Returns the original name of the specified column.
+
+ The index of the column.
+ string
+
+
+
+ Retrieves the i of a column, given its name
+
+ The name of the column to retrieve
+ The int i of the column
+
+
+
+ Schema information in SQLite is difficult to map into .NET conventions, so a lot of work must be done
+ to gather the necessary information so it can be represented in an ADO.NET manner.
+ Returns a DataTable containing the schema information for the active SELECT statement being processed.
-
+
- Clears the connection pool associated with the connection. Any other active connections using the same database file
- will be discarded instead of returned to the pool when they are closed.
+ Retrieves the column as a string
-
+ The index of the column.
+ string
-
+
- Clears all connection pools. Any active connections will be discarded instead of sent to the pool when they are closed.
+ Retrieves the column as an object corresponding to the underlying datatype of the column
+ The index of the column.
+ object
-
+
- Create a new and associate it with this connection.
+ Retreives the values of multiple columns, up to the size of the supplied array
- Returns a new command object already assigned to this connection.
+ The array to fill with values from the columns in the current resultset
+ The number of columns retrieved
-
+
- Forwards to the local function.
+ Returns a collection containing all the column names and values for the
+ current row of data in the current resultset, if any. If there is no
+ current row or no current resultset, an exception may be thrown.
-
+
+ The collection containing the column name and value information for the
+ current row of data in the current resultset or null if this information
+ cannot be obtained.
+
-
+
- Parses the connection string into component parts using the custom
- connection string parser.
+ Returns True if the resultset has rows that can be fetched
- The connection string to parse
- An array of key-value pairs representing each parameter of the connection string
-
+
- Parses a connection string using the built-in (i.e. framework provided)
- connection string parser class and returns the key/value pairs. An
- exception may be thrown if the connection string is invalid or cannot be
- parsed. When compiled for the .NET Compact Framework, the custom
- connection string parser is always used instead because the framework
- provided one is unavailable there.
+ Returns True if the data reader is closed
-
- The connection string to parse.
-
-
- Non-zero to throw an exception if any connection string values are not of
- the type.
-
- The list of key/value pairs.
-
+
- Manual distributed transaction enlistment support
+ Returns True if the specified column is null
- The distributed transaction to enlist in
+ The index of the column.
+ True or False
-
+
- Looks for a key in the array of key/values of the parameter string. If not found, return the specified default value
+ Moves to the next resultset in multiple row-returning SQL command.
- The list to look in
- The key to find
- The default value to return if the key is not found
- The value corresponding to the specified key, or the default value if not found.
+ True if the command was successful and a new resultset is available, False otherwise.
-
+
- Attempts to convert the string value to an enumerated value of the specified type.
+ This method attempts to query the database connection associated with
+ the data reader in use. If the underlying command or connection is
+ unavailable, a null value will be returned.
- The enumerated type to convert the string value to.
- The string value to be converted.
- Non-zero to make the conversion case-insensitive.
- The enumerated value upon success or null upon error.
+
+ The connection object -OR- null if it is unavailable.
+
-
+
- Attempts to convert an input string into a byte value.
+ Retrieves the SQLiteType for a given column and row value.
-
- The string value to be converted.
-
-
- The number styles to use for the conversion.
+
+ The original SQLiteType structure, based only on the column.
-
- Upon sucess, this will contain the parsed byte value.
- Upon failure, the value of this parameter is undefined.
+
+ The textual value of the column for a given row.
- Non-zero upon success; zero on failure.
+ The SQLiteType structure.
-
+
- Enables or disabled extension loading.
+ Retrieves the SQLiteType for a given column, and caches it to avoid repetetive interop calls.
-
- True to enable loading of extensions, false to disable.
-
+ The flags associated with the parent connection object.
+ The index of the column.
+ A SQLiteType structure
-
+
- Loads a SQLite extension library from the named dynamic link library file.
+ Reads the next row from the resultset
-
- The name of the dynamic link library file containing the extension.
-
+ True if a new row was successfully loaded and is ready for processing
-
+
- Loads a SQLite extension library from the named dynamic link library file.
+ Returns the number of rows affected by the statement being executed.
+ The value returned may not be accurate for DDL statements. Also, it
+ will be -1 for any statement that does not modify the database (e.g.
+ SELECT). If an otherwise read-only statement modifies the database
+ indirectly (e.g. via a virtual table or user-defined function), the
+ value returned is undefined.
-
- The name of the dynamic link library file containing the extension.
+
+
+
+ Indexer to retrieve data from a column given its name
+
+ The name of the column to retrieve data for
+ The value contained in the column
+
+
+
+ Indexer to retrieve data from a column given its i
+
+ The index of the column.
+ The value contained in the column
+
+
+
+ SQLite exception class.
+
+
+
+
+ This value was copied from the "WinError.h" file included with the
+ Platform SDK for Windows 10.
+
+
+
+
+ Private constructor for use with serialization.
+
+
+ Holds the serialized object data about the exception being thrown.
-
- The name of the exported function used to initialize the extension.
- If null, the default "sqlite3_extension_init" will be used.
+
+ Contains contextual information about the source or destination.
-
+
- Creates a disposable module containing the implementation of a virtual
- table.
+ Public constructor for generating a SQLite exception given the error
+ code and message.
-
- The module object to be used when creating the disposable module.
+
+ The SQLite return code to report.
+
+
+ Message text to go along with the return code message text.
-
+
- Parses a string containing a sequence of zero or more hexadecimal
- encoded byte values and returns the resulting byte array. The
- "0x" prefix is not allowed on the input string.
+ Public constructor that uses the base class constructor for the error
+ message.
-
- The input string containing zero or more hexadecimal encoded byte
- values.
-
-
- A byte array containing the parsed byte values or null if an error
- was encountered.
-
+ Error message text.
-
+
- Creates and returns a string containing the hexadecimal encoded byte
- values from the input array.
+ Public constructor that uses the default base class constructor.
-
- The input array of bytes.
-
-
- The resulting string or null upon failure.
-
-
+
- Parses a string containing a sequence of zero or more hexadecimal
- encoded byte values and returns the resulting byte array. The
- "0x" prefix is not allowed on the input string.
+ Public constructor that uses the base class constructor for the error
+ message and inner exception.
-
- The input string containing zero or more hexadecimal encoded byte
- values.
+ Error message text.
+ The original (inner) exception.
+
+
+
+ Adds extra information to the serialized object data specific to this
+ class type. This is only used for serialization.
+
+
+ Holds the serialized object data about the exception being thrown.
-
- Upon failure, this will contain an appropriate error message.
+
+ Contains contextual information about the source or destination.
-
- A byte array containing the parsed byte values or null if an error
- was encountered.
-
-
+
- This method figures out what the default connection pool setting should
- be based on the connection flags. When present, the "Pooling" connection
- string property value always overrides the value returned by this method.
+ Gets the associated SQLite result code for this exception as a
+