diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json new file mode 100644 index 000000000..a82e49732 --- /dev/null +++ b/frontend/.csscomb.json @@ -0,0 +1,25 @@ +{ + "remove-empty-rulesets": true, + "always-semicolon": true, + "color-case": "lower", + "block-indent": " ", + "color-shorthand": false, + "element-case": "lower", + "eof-newline": true, + "leading-zero": true, + "quotes": "double", + "sort-order-fallback": "abc", + "space-before-colon": "", + "space-after-colon": " ", + "space-before-combinator": " ", + "space-after-combinator": " ", + "space-between-declarations": "\n", + "space-before-opening-brace": " ", + "space-after-opening-brace": "\n", + "space-after-selector-delimiter": " ", + "space-before-selector-delimiter": "", + "space-before-closing-brace": "\n", + "strip-spaces": true, + "tab-size": true, + "unitless-zero": false +} diff --git a/frontend/.esformatter b/frontend/.esformatter new file mode 100644 index 000000000..600bb0751 --- /dev/null +++ b/frontend/.esformatter @@ -0,0 +1,335 @@ +{ + "indent": { + "value": " ", + "FunctionExpression": 1, + "ArrayExpression": 1, + "ObjectExpression": 1 + }, + "lineBreak": { + "value": "\n", + + "before": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": ">=1", + "ArrowFunctionExpressionOpeningBrace": 0, + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": ">=1", + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": 0, + "CatchClosingBrace": ">=1", + "CatchKeyword": 0, + "CatchOpeningBrace": 0, + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": 0, + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": ">=1", + "DoWhileStatementOpeningBrace": 0, + "ElseIfStatement": 0, + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": 0, + "ElseStatement": 0, + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": 0, + "EmptyStatement": -1, + "EndOfFile": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": 0, + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 0, + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": "<2", + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 0, + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace":0, + "IIFEClosingParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": 0, + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": -1, + "MethodDefinition": ">=1", + "ObjectExpressionClosingBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": "<=2", + "PropertyValue": 0, + "ReturnStatement": -1, + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": 0, + "ThisExpression": -1, + "ThrowStatement": ">=1", + "TryClosingBrace": ">=1", + "TryKeyword": -1, + "TryOpeningBrace": 0, + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": 0, + "VariableDeclarationWithoutInit": ">=1", + "VariableName": ">=1", + "VariableValue": 0, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": 0 + }, + + "after": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": -1, + "ArrowFunctionExpressionOpeningBrace": ">=1", + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": -1, + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": -1, + "CatchClosingBrace": ">=0", + "CatchKeyword": 0, + "CatchOpeningBrace": ">=1", + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": ">=1", + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": 0, + "DoWhileStatementOpeningBrace": ">=1", + "ElseIfStatement": ">=1", + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": ">=1", + "ElseStatement": ">=1", + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": ">=1", + "EmptyStatement": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": ">=1", + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": -1, + "ForInStatementExpressionOpening": "<2", + "ForInStatementOpeningBrace": ">=1", + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": -1, + "ForStatementExpressionOpening": "<2", + "ForStatementOpeningBrace": ">=1", + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": ">=1", + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": -1, + "FunctionExpressionOpeningBrace": 1, + "IIFEOpeningParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": ">=1", + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": 0, + "MethodDefinition": ">=1", + "ObjectExpressionOpeningBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": -1, + "PropertyName": 0, + "ReturnStatement": -1, + "SwitchCaseColon": ">=1", + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": ">=1", + "ThisExpression": 0, + "ThrowStatement": ">=1", + "TryClosingBrace": 0, + "TryKeyword": -1, + "TryOpeningBrace": ">=1", + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": ">=1", + "VariableValue": -1, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": ">=1" + } + }, + "whiteSpace": { + "value": " ", + "removeTrailing": 1, + "before": { + "ArgumentComma": 0, + "ArgumentList": 0, + "ArgumentListArrayExpression": 0, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 0, + "ArrayExpressionOpening": 1, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 1, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 0, + "ConditionalExpressionAlternate": 1, + "ConditionalExpressionConsequent": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementConditional": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionClosingParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 1, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 0, + "ForStatementExpressionOpening": 1, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 0, + "FunctionDeclarationClosingBrace": 1, + "FunctionDeclarationOpeningBrace": 1, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace": 1, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 1, + "IfStatementOpeningBrace": 1, + "LineComment": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionClosing": 0, + "ObjectExpressionClosingBrace": 1, + "ParameterComma": 0, + "ParameterList": 0, + "Property": 1, + "PropertyName": 1, + "PropertyValue": 1, + "SwitchDiscriminantClosing": 0, + "SwitchDiscriminantOpening": 1, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "VariableValue": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 0, + "WhileStatementConditionalOpening": 1, + "WhileStatementOpeningBrace": 1 + }, + "after": { + "ArgumentComma": 1, + "ArgumentList": 0, + "ArgumentListArrayExpression": 1, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 1, + "ArrayExpressionOpening": 0, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 0, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 1, + "ConditionalExpressionConsequent": 1, + "ConditionalExpressionTest": 1, + "DoWhileStatementBody": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionOpeningParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 1, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 1, + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 1, + "FunctionDeclarationClosingBrace": 0, + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpressionClosingBrace": 0, + "FunctionExpressionOpeningBrace": 0, + "FunctionName": 0, + "FunctionReservedWord": 0, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 0, + "IfStatementOpeningBrace": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionOpening": 0, + "ObjectExpressionClosingBrace": 0, + "ObjectExpressionOpeningBrace": 1, + "ParameterComma": 1, + "ParameterList": 0, + "PropertyName": 0, + "PropertyValue": 0, + "SwitchDiscriminantClosing": 1, + "SwitchDiscriminantOpening": 0, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 1, + "WhileStatementConditionalOpening": 0, + "WhileStatementOpeningBrace": 1 + } + } +} diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..d4b43f836 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1 @@ +**/JsLibraries/** diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 000000000..31b1173ec --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,288 @@ +{ + "parser": "babel-eslint", + + "env": { + "browser": true, + "commonjs": true, + "node": true, + "es6": true + }, + + "globals": { + "expect": false, + "chai": false, + "sinon": false + }, + + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "modules": true, + "impliedStrict": true + } + }, + + "plugins": [ + "filenames", + "react" + ], + + "rules": { + "filenames/match-exported": ["error"], + + # ECMAScript 6 + + "arrow-body-style": [0], + "arrow-parens": ["error", "always"], + "arrow-spacing": ["error", { "before": true, "after": true }], + "constructor-super": "error", + "generator-star-spacing": "off", + "no-class-assign": "error", + "no-confusing-arrow": "error", + "no-const-assign": "error", + "no-dupe-class-members": "error", + "no-duplicate-imports": "error", + "no-new-symbol": "error", + "no-this-before-super": "error", + "no-useless-escape": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-var": "warn", + "object-shorthand": ["error", "properties"], + "prefer-arrow-callback": "error", + "prefer-const": "warn", + "prefer-reflect": "off", + "prefer-rest-params": "off", + "prefer-spread": "warn", + "prefer-template": "error", + "require-yield": "off", + "template-curly-spacing": ["error", "never"], + "yield-star-spacing": "off", + + # Possible Errors + + "comma-dangle": "error", + "no-cond-assign": "error", + "no-console": "off", + "no-constant-condition": "warn", + "no-control-regex": "error", + "no-debugger": "off", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "warn", + "no-empty-character-class": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-extra-parens": ["error", "functions"], + "no-extra-semi": "error", + "no-func-assign": "error", + "no-inner-declarations": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-negated-in-lhs": "error", + "no-obj-calls": "error", + "no-regex-spaces": "error", + "no-sparse-arrays": "error", + "no-unexpected-multiline": "error", + "no-unreachable": "warn", + "no-unsafe-finally": "error", + "use-isnan": "error", + "valid-jsdoc": "off", + "valid-typeof": "error", + + # Best Practices + + "accessor-pairs": "off", + "array-callback-return": "warn", + "block-scoped-var": "warn", + "consistent-return": "off", + "curly": "error", + "default-case": "error", + "dot-location": ["error", "property"], + "dot-notation": "error", + "eqeqeq": ["error", "smart"], + "guard-for-in": "error", + "no-alert": "warn", + "no-caller": "error", + "no-case-declarations": "error", + "no-div-regex": "error", + "no-else-return": "error", + "no-empty-function": ["error", {"allow": ["arrowFunctions"]}], + "no-empty-pattern": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-fallthrough": "error", + "no-floating-decimal": "error", + "no-implicit-coercion": ["error", { + "boolean": false, + "number": true, + "string": true, + "allow": [/* "!!", "~", "*", "+" */] + }], + "no-implicit-globals": "error", + "no-implied-eval": "error", + "no-invalid-this": "off", + "no-iterator": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-loop-func": "error", + "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }], + "no-multi-spaces": "error", + "no-multi-str": "error", + "no-native-reassign": ["error", {"exceptions": ["console"]}], + "no-new": "off", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-param-reassign": "off", + "no-process-env": "off", + "no-proto": "error", + "no-redeclare": "error", + "no-return-assign": "warn", + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-useless-call": "error", + "no-useless-concat": "error", + "no-void": "error", + "no-warning-comments": "off", + "no-with": "error", + "radix": ["error", "as-needed"], + "vars-on-top": "off", + "wrap-iife": ["error", "inside"], + "yoda": "error", + + # Strict Mode + + "strict": ["error", "never"], + + # Variables + + "init-declarations": ["error", "always"], + "no-catch-shadow": "error", + "no-delete-var": "error", + "no-label-var": "error", + "no-restricted-globals": "off", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-undef": "error", + "no-undef-init": "off", + "no-undefined": "off", + "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }], + "no-use-before-define": "error", + + # Node.js and CommonJS + + "callback-return": "warn", + "global-require": "error", + "handle-callback-err": "warn", + "no-mixed-requires": "error", + "no-new-require": "error", + "no-path-concat": "error", + "no-process-exit": "error", + + # Stylistic Issues + + "array-bracket-spacing": ["error", "never"], + "block-spacing": ["error", "always"], + "brace-style": ["error", "1tbs", { "allowSingleLine": false }], + "camelcase": "off", + "comma-spacing": ["error", {"before": false, "after": true}], + "comma-style": ["error", "last"], + "computed-property-spacing": ["error", "never"], + "consistent-this": ["error", "self"], + "eol-last": "error", + "func-names": "off", + "func-style": ["error", "declaration"], + "indent": ["error", 2, {"SwitchCase": 1}], + "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], + "keyword-spacing": ["error", { "before": true, "after": true}], + "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }], + "max-depth": ["error", {"maximum": 5}], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 6], + "max-statements": "off", + "max-statements-per-line": ["error", { "max": 1 }], + "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}], + "new-parens": "error", + "newline-after-var": "off", + "newline-before-return": "off", + "newline-per-chained-call": "off", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-continue": "error", + "no-inline-comments": "off", + "no-lonely-if": "warn", + "no-mixed-spaces-and-tabs": "error", + "no-multiple-empty-lines": ["error", { "max": 1 }], + "no-negated-condition": "warn", + "no-nested-ternary": "error", + "no-new-object": "error", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "no-spaced-func": "error", + "no-ternary": "off", + "no-trailing-spaces": "error", + "no-underscore-dangle": ["error", { "allowAfterThis": true }], + "no-unneeded-ternary": "error", + "no-whitespace-before-property": "error", + "object-curly-spacing": ["error", "always"], + "one-var": ["error", "never"], + "one-var-declaration-per-line": ["error", "always"], + "operator-assignment": ["off", "never"], + "operator-linebreak": ["error", "after"], + "quote-props": ["error", "as-needed"], + "quotes": ["error", "single"], + "require-jsdoc": "off", + "semi": "error", + "semi-spacing": ["error", { "before": false, "after": true }], + "sort-vars": "off", + "space-before-blocks": ["error", "always"], + "space-before-function-paren": ["error", "never"], + "space-in-parens": "off", + "space-infix-ops": "off", + "space-unary-ops": "off", + "spaced-comment": "error", + "wrap-regex": "error", + + # React + + "react/jsx-boolean-value": [2, "always"], + "react/jsx-uses-vars": 2, + "react/jsx-closing-bracket-location": 2, + "react/jsx-tag-spacing": ["error"], + "react/jsx-curly-spacing": [2, "never"], + "react/jsx-equals-spacing": [2, "never"], + "react/jsx-indent-props": [2, 2], + "react/jsx-indent": [2, 2], + "react/jsx-key": 2, + "react/jsx-no-bind": [2, { "allowArrowFunctions": true }], + "react/jsx-no-duplicate-props": [2, { "ignoreCase": true }], + "react/jsx-max-props-per-line": [2, { "maximum": 2 }], + "react/jsx-handler-names": [2, { "eventHandlerPrefix": "(on|dispatch)", "eventHandlerPropPrefix": "on" }], + "react/jsx-no-undef": 2, + "react/jsx-pascal-case": 2, + "react/jsx-uses-react": 2, + // Explicitly disabled in case we want to enable them again + "react/no-did-mount-set-state": 0, + "react/no-did-update-set-state": 0, + "react/no-direct-mutation-state": 2, + "react/no-multi-comp": [2, { "ignoreStateless": true }], + "react/no-unknown-property": 2, + "react/prefer-es6-class": 2, + "react/prop-types": 2, + "react/react-in-jsx-scope": 2, + "react/self-closing-comp": 2, + "react/sort-comp": 2, + "react/jsx-wrap-multilines": 2 + } +} diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc new file mode 100644 index 000000000..50aa6aa29 --- /dev/null +++ b/frontend/.jsbeautifyrc @@ -0,0 +1,12 @@ +{ + "js": { + "indent_size": 2, + "indent_char": " ", + "indent_level": 2, + "indent_with_tabs": false, + "preserve_newlines": true, + "brace_style": "collapse", + "max_preserve_newlines": 2, + "jslint_happy": true + } +} \ No newline at end of file diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc new file mode 100644 index 000000000..5587e5d4d --- /dev/null +++ b/frontend/.stylelintrc @@ -0,0 +1,396 @@ +{ +"plugins": [ + "stylelint-order" +], +"ignoreFiles": [ + "frontend/src/Styles/scaffolding.css", + "**/*.js" +], +"rules": { + "at-rule-empty-line-before": [ + "always", + { + "except": [ + "inside-block" + ] + } + ], + "at-rule-name-case": "lower", + "at-rule-name-newline-after": "always-multi-line", + "at-rule-name-space-after": "always", + "at-rule-no-unknown": [ + true, + { + "ignoreAtRules": [ + "/^add\\-mixin$/", + "/^define\\-mixin$/" + ] + } + ], + "at-rule-no-vendor-prefix": true, + "at-rule-semicolon-newline-after": "always", + "at-rule-semicolon-space-before": "never", + "block-closing-brace-empty-line-before": "never", + "block-closing-brace-newline-after": "always", + "block-closing-brace-newline-before": "always", + "block-closing-brace-space-after": "always-single-line", + "block-closing-brace-space-before": "always-single-line", + "block-no-empty": true, + "block-opening-brace-newline-after": "always", + "block-opening-brace-newline-before": "never-single-line", + "block-opening-brace-space-after": "always-single-line", + "block-opening-brace-space-before": "always", + "color-hex-case": "lower", + "color-hex-length": "short", + "color-named": "never", + "color-no-invalid-hex": true, + "comment-whitespace-inside": "always", + "declaration-bang-space-after": "never", + "declaration-bang-space-before": "always", + "declaration-block-no-duplicate-properties": [ + true, + { + "ignoreProperties": [ + "composes" + ] + } + ], + "declaration-block-no-redundant-longhand-properties": true, + "declaration-block-no-shorthand-property-overrides": true, + "declaration-block-semicolon-newline-after": "always", + "declaration-block-semicolon-newline-before": "never-multi-line", + "declaration-block-semicolon-space-before": "never", + "declaration-block-single-line-max-declarations": 1, + "declaration-block-trailing-semicolon": "always", + "declaration-colon-space-after": "always", + "declaration-colon-space-before": "never", + "font-family-name-quotes": "always-unless-keyword", + "function-calc-no-unspaced-operator": true, + "function-comma-newline-after": "never-multi-line", + "function-comma-newline-before": "never-multi-line", + "function-comma-space-after": "always", + "function-comma-space-before": "never", + "function-linear-gradient-no-nonstandard-direction": true, + "function-name-case": "lower", + "function-parentheses-newline-inside": "never-multi-line", + "function-parentheses-space-inside": "never", + "function-url-quotes": "always", + "function-url-scheme-blacklist": [ + "data" + ], + "function-whitespace-after": "always", + "indentation": 2, + "keyframe-declaration-no-important": true, + "length-zero-no-unit": true, + "max-empty-lines": 1, + "max-line-length": [ + 100, + { + "ignore": [ + "non-comments" + ] + } + ], + "max-nesting-depth": 2, + "media-feature-colon-space-after": "always", + "media-feature-colon-space-before": "never", + "media-feature-name-case": "lower", + "media-feature-name-no-vendor-prefix": true, + "media-feature-range-operator-space-after": "always", + "media-feature-range-operator-space-before": "always", + "no-empty-source": true, + "no-eol-whitespace": true, + "no-extra-semicolons": true, + "no-invalid-double-slash-comments": true, + "no-missing-end-of-source-newline": true, + "number-leading-zero": "always", + "number-no-trailing-zeros": true, + "order/order": [ + "custom-properties", + "dollar-variables", + { + "hasBlock": false, + "name": "add-mixin", + "type": "at-rule" + }, + "declarations", + "rules", + "at-rules" + ], + "order/properties-order": [ + { + "emptyLineBefore": "always", + "properties": [ + "composes" + ] + }, + { + "emptyLineBefore": "always", + "properties": [ + "position", + "top", + "right", + "bottom", + "left", + "z-index", + "display", + "visibility", + "align-content", + "align-items", + "align-self", + "justify-content", + "flex", + "flex-direction", + "flex-order", + "flex-pack", + "flex-align", + "flex-grow", + "flex-shrink", + "flex-basis", + "flex-wrap", + "flex-flow", + "float", + "clear", + "overflow", + "overflow-x", + "overflow-y", + "-webkit-overflow-scrolling", + "clip", + "box-sizing", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "min-width", + "min-height", + "max-width", + "max-height", + "width", + "height", + "outline", + "outline-width", + "outline-style", + "outline-color", + "outline-offset", + "border", + "border-spacing", + "border-collapse", + "border-width", + "border-style", + "border-color", + "border-top", + "border-top-width", + "border-top-style", + "border-top-color", + "border-right", + "border-right-width", + "border-right-style", + "border-right-color", + "border-bottom", + "border-bottom-width", + "border-bottom-style", + "border-bottom-color", + "border-left", + "border-left-width", + "border-left-style", + "border-left-color", + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-image", + "border-image-source", + "border-image-slice", + "border-image-width", + "border-image-outset", + "border-image-repeat", + "border-top-image", + "border-right-image", + "border-bottom-image", + "border-left-image", + "border-corner-image", + "border-top-left-image", + "border-top-right-image", + "border-bottom-right-image", + "border-bottom-left-image", + "background", + "background-color", + "background-image", + "background-attachment", + "background-position", + "background-position-x", + "background-position-y", + "background-clip", + "background-origin", + "background-size", + "background-repeat", + "box-decoration-break", + "box-shadow", + "color", + "table-layout", + "caption-side", + "empty-cells", + "list-style", + "list-style-position", + "list-style-type", + "list-style-image", + "quotes", + "content", + "counter-increment", + "counter-reset", + "-ms-writing-mode", + "vertical-align", + "text-align", + "text-align-last", + "text-decoration", + "text-emphasis", + "text-emphasis-position", + "text-emphasis-style", + "text-emphasis-color", + "text-indent", + "text-justify", + "text-outline", + "text-transform", + "text-wrap", + "text-overflow", + "text-overflow-ellipsis", + "text-overflow-mode", + "text-shadow", + "white-space", + "word-spacing", + "word-wrap", + "word-break", + "tab-size", + "hyphens", + "letter-spacing", + "font", + "font-weight", + "font-style", + "font-variant", + "font-size-adjust", + "font-stretch", + "font-size", + "font-family", + "font-smoothing", + "-moz-osx-font-smoothing", + "-webkit-font-smoothing", + "src", + "line-height", + "opacity", + "filter", + "resize", + "cursor", + "appearance", + "nav-index", + "nav-up", + "nav-right", + "nav-down", + "nav-left", + "transition", + "transition-delay", + "transition-timing-function", + "transition-duration", + "transition-property", + "transform", + "transform-origin", + "transform-style", + "backface-visibility", + "animation", + "animation-name", + "animation-duration", + "animation-play-state", + "animation-timing-function", + "animation-delay", + "animation-iteration-count", + "animation-direction", + "animation-fill-mode", + "pointer-events", + "user-select", + "touch-action", + "-webkit-tap-highlight-color", + "unicode-bidi", + "direction", + "columns", + "column-span", + "column-width", + "column-count", + "column-fill", + "column-gap", + "column-rule", + "column-rule-width", + "column-rule-style", + "column-rule-color", + "break-before", + "break-inside", + "break-after", + "page-break-before", + "page-break-inside", + "page-break-after", + "orphans", + "widows", + "zoom", + "max-zoom", + "min-zoom", + "user-zoom", + "orientation" + ] + } + ], + "property-case": "lower", + "property-no-vendor-prefix": true, + "rule-empty-line-before": [ + "always", + { + "except": [ + "first-nested" + ], + "ignore": [ + "after-comment" + ] + } + ], + "selector-attribute-brackets-space-inside": "never", + "selector-attribute-operator-space-after": "never", + "selector-attribute-operator-space-before": "never", + "selector-attribute-quotes": "never", + "selector-class-pattern": "^[A-Za-z0-9]+$", + "selector-combinator-space-after": "always", + "selector-combinator-space-before": "always", + "selector-descendant-combinator-no-non-space": true, + "selector-list-comma-newline-after": "always", + "selector-list-comma-newline-before": "never-multi-line", + "selector-list-comma-space-before": "never", + "selector-max-attribute": 0, + "selector-max-class": 3, + "selector-max-compound-selectors": 3, + "selector-max-empty-lines": 0, + "selector-max-id": 0, + "selector-max-universal": 0, + "selector-pseudo-class-case": "lower", + "selector-pseudo-class-parentheses-space-inside": "never", + "selector-pseudo-element-case": "lower", + "selector-pseudo-element-colon-notation": "double", + "selector-pseudo-element-no-unknown": true, + "selector-type-case": "lower", + "selector-type-no-unknown": true, + "shorthand-property-no-redundant-values": true, + "string-no-newline": true, + "string-quotes": "single", + "time-min-milliseconds": 100, + "unit-case": "lower", + "unit-no-unknown": true, + "value-list-comma-newline-after": "never-multi-line", + "value-list-comma-newline-before": "never-multi-line", + "value-list-comma-space-after": "always", + "value-list-comma-space-before": "never", + "value-list-max-empty-lines": 0, + "value-no-vendor-prefix": true + } +} diff --git a/frontend/.tern-project b/frontend/.tern-project new file mode 100644 index 000000000..aa9d76407 --- /dev/null +++ b/frontend/.tern-project @@ -0,0 +1,7 @@ +{ + "ecmaVersion": 6, + "libs": [ + "browser", + "jquery" + ] +} diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js new file mode 100644 index 000000000..cfeb5d138 --- /dev/null +++ b/frontend/gulp/build.js @@ -0,0 +1,15 @@ +const gulp = require('gulp'); +const runSequence = require('run-sequence'); + +require('./clean'); +require('./copy'); + +gulp.task('build', () => { + return runSequence('clean', [ + 'webpack', + 'copyHtml', + 'copyFonts', + 'copyImages', + 'copyJs' + ]); +}); diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js new file mode 100644 index 000000000..ac2e4026f --- /dev/null +++ b/frontend/gulp/clean.js @@ -0,0 +1,8 @@ +const gulp = require('gulp'); +const del = require('del'); + +const paths = require('./helpers/paths'); + +gulp.task('clean', () => { + return del([paths.dest.root]); +}); diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js new file mode 100644 index 000000000..5b48eb755 --- /dev/null +++ b/frontend/gulp/copy.js @@ -0,0 +1,45 @@ +var path = require('path'); +var gulp = require('gulp'); +var print = require('gulp-print').default; +var cache = require('gulp-cached'); +var livereload = require('gulp-livereload'); +var paths = require('./helpers/paths.js'); + +gulp.task('copyJs', () => { + return gulp.src( + [ + path.join(paths.src.root, 'polyfills.js') + ]) + .pipe(cache('copyJs')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyHtml', () => { + return gulp.src(paths.src.html) + .pipe(cache('copyHtml')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyFonts', () => { + return gulp.src( + path.join(paths.src.fonts, '**', '*.*') + ) + .pipe(cache('copyFonts')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.fonts)) + .pipe(livereload()); +}); + +gulp.task('copyImages', () => { + return gulp.src( + path.join(paths.src.images, '**', '*.*') + ) + .pipe(cache('copyImages')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.images)) + .pipe(livereload()); +}); diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js new file mode 100644 index 000000000..744dd8d7e --- /dev/null +++ b/frontend/gulp/gulpFile.js @@ -0,0 +1,8 @@ +require('./build.js'); +require('./clean.js'); +require('./copy.js'); +require('./imageMin.js'); +require('./start.js'); +require('./stripBom.js'); +require('./watch.js'); +require('./webpack.js'); diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js new file mode 100644 index 000000000..f3e1c113b --- /dev/null +++ b/frontend/gulp/helpers/errorHandler.js @@ -0,0 +1,6 @@ +const gulpUtil = require('gulp-util'); + +module.exports = function errorHandler(error) { + gulpUtil.log(gulpUtil.colors.red(`Error (${error.plugin}): ${error.message}`)); + this.emit('end'); +}; diff --git a/frontend/gulp/helpers/html-annotate-loader.js b/frontend/gulp/helpers/html-annotate-loader.js new file mode 100644 index 000000000..6c7ce10b8 --- /dev/null +++ b/frontend/gulp/helpers/html-annotate-loader.js @@ -0,0 +1,15 @@ +const path = require('path'); +const rootPath = path.resolve(__dirname + '/../../src/'); +module.exports = function(source) { + if (this.cacheable) { + this.cacheable(); + } + + const resourcePath = this.resourcePath.replace(rootPath, ''); + const wrappedSource =` + + ${source} + `; + + return wrappedSource; +}; diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js new file mode 100644 index 000000000..b96b5aaeb --- /dev/null +++ b/frontend/gulp/helpers/paths.js @@ -0,0 +1,23 @@ +const root = './frontend/src/'; + +const paths = { + src: { + root, + html: root + '*.html', + scripts: root + '**/*.js', + content: root + 'Content/', + fonts: root + 'Content/Fonts/', + images: root + 'Content/Images/', + exclude: { + libs: `!${root}JsLibraries/**` + } + }, + dest: { + root: './_output/UI/', + content: './_output/UI/Content/', + fonts: './_output/UI/Content/Fonts/', + images: './_output/UI/Content/Images/' + } +}; + +module.exports = paths; diff --git a/frontend/gulp/imageMin.js b/frontend/gulp/imageMin.js new file mode 100644 index 000000000..8988c7ad4 --- /dev/null +++ b/frontend/gulp/imageMin.js @@ -0,0 +1,15 @@ +var gulp = require('gulp'); +var print = require('gulp-print').default; +var paths = require('./helpers/paths.js'); + +gulp.task('imageMin', () => { + var imagemin = require('gulp-imagemin'); + return gulp.src(paths.src.images) + .pipe(imagemin({ + progressive: false, + optimizationLevel: 4, + svgoPlugins: [{ removeViewBox: false }] + })) + .pipe(print()) + .pipe(gulp.dest(paths.src.content + 'Images/')); +}); diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js new file mode 100644 index 000000000..013250194 --- /dev/null +++ b/frontend/gulp/start.js @@ -0,0 +1,104 @@ +// will download and run radarr (server) in a non-windows enviroment +// you can use this if you don't care about the server code and just want to work +// with the web code. + +var http = require('http'); +var gulp = require('gulp'); +var fs = require('fs'); +var targz = require('tar.gz'); +var del = require('del'); +var spawn = require('child_process').spawn; + +function download(url, dest, cb) { + console.log('Downloading ' + url + ' to ' + dest); + var file = fs.createWriteStream(dest); + http.get(url, function(response) { + response.pipe(file); + file.on('finish', function() { + console.log('Download completed'); + file.close(cb); + }); + }); +} + +function getLatest(cb) { + var branch = 'develop'; + process.argv.forEach(function(val) { + var branchMatch = /branch=([\S]*)/.exec(val); + if (branchMatch && branchMatch.length > 1) { + branch = branchMatch[1]; + } + }); + + var url = 'http://radarr.aeonlucid.com/v1/update/' + branch + '?os=osx'; + + console.log('Checking for latest version:', url); + + http.get(url, function(res) { + var data = ''; + + res.on('data', function(chunk) { + data += chunk; + }); + + res.on('end', function() { + var updatePackage = JSON.parse(data).updatePackage; + console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate); + cb(updatePackage); + }); + }).on('error', function(e) { + console.log('problem with request: ' + e.message); + }); +} + +function extract(source, dest, cb) { + console.log('extracting download page to ' + dest); + new targz().extract(source, dest, function(err) { + if (err) { + console.log(err); + } + console.log('Update package extracted.'); + cb(); + }); +} + +gulp.task('getSonarr', function() { + try { + fs.mkdirSync('./_start/'); + } catch (e) { + if (e.code !== 'EEXIST') { + throw e; + } + } + + getLatest(function(updatePackage) { + var packagePath = './_start/' + updatePackage.filename; + var dirName = './_start/' + updatePackage.version; + download(updatePackage.url, packagePath, function() { + extract(packagePath, dirName, function() { + // clean old binaries + console.log('Cleaning old binaries'); + del.sync(['./_output/*', '!./_output/UI/']); + console.log('copying binaries to target'); + gulp.src(dirName + '/NzbDrone/*.*') + .pipe(gulp.dest('./_output/')); + }); + }); + }); +}); + +gulp.task('startSonarr', function() { + var ls = spawn('mono', ['--debug', './_output/Radarr.exe']); + + ls.stdout.on('data', function(data) { + process.stdout.write(data); + }); + + ls.stderr.on('data', function(data) { + process.stdout.write(data); + }); + + ls.on('close', function(code) { + console.log('child process exited with code ' + code); + }); +}); diff --git a/frontend/gulp/stripBom.js b/frontend/gulp/stripBom.js new file mode 100644 index 000000000..080b86dfe --- /dev/null +++ b/frontend/gulp/stripBom.js @@ -0,0 +1,13 @@ +const gulp = require('gulp'); +const paths = require('./helpers/paths.js'); +const stripbom = require('gulp-stripbom'); + +function stripBom(dest) { + gulp.src([paths.src.scripts, paths.src.exclude.libs]) + .pipe(stripbom({ showLog: false })) + .pipe(gulp.dest(dest)); +} + +gulp.task('stripBom', () => { + stripBom(paths.src.root); +}); diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js new file mode 100644 index 000000000..ba1a47b66 --- /dev/null +++ b/frontend/gulp/watch.js @@ -0,0 +1,27 @@ +const gulp = require('gulp'); +const livereload = require('gulp-livereload'); +const watch = require('gulp-watch'); +const paths = require('./helpers/paths.js'); + +require('./copy.js'); +require('./webpack.js'); + +function watchTask(glob, task) { + const options = { + name: `watch: ${task}`, + verbose: true + }; + return watch(glob, options, () => { + gulp.start(task); + }); +} + +gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => { + livereload.listen({ start: true }); + + gulp.start('webpackWatch'); + + watchTask(paths.src.html, 'copyHtml'); + watchTask(`${paths.src.fonts}**/*.*`, 'copyFonts'); + watchTask(`${paths.src.images}**/*.*`, 'copyImages'); +}); diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js new file mode 100644 index 000000000..50aefcc1a --- /dev/null +++ b/frontend/gulp/webpack.js @@ -0,0 +1,212 @@ +const gulp = require('gulp'); +const webpackStream = require('webpack-stream'); +const livereload = require('gulp-livereload'); +const path = require('path'); +const webpack = require('webpack'); +const errorHandler = require('./helpers/errorHandler'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); + +const uiFolder = 'UI'; +const root = path.join(__dirname, '..', 'src'); +const isProduction = process.argv.indexOf('--production') > -1; + +console.log('ROOT:', root); +console.log('isProduction:', isProduction); + +const cssVarsFiles = [ + '../src/Styles/Variables/colors', + '../src/Styles/Variables/dimensions', + '../src/Styles/Variables/fonts', + '../src/Styles/Variables/animations' +].map(require.resolve); + +const extractCSSPlugin = new ExtractTextPlugin({ + filename: path.join('_output', uiFolder, 'Content', 'styles.css'), + allChunks: true, + disable: false, + ignoreOrder: true +}); + +const plugins = [ + extractCSSPlugin, + + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor' + }), + + new webpack.DefinePlugin({ + __DEV__: !isProduction, + 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development') + }) +]; + +if (isProduction) { + plugins.push(new UglifyJSPlugin({ + sourceMap: true, + uglifyOptions: { + mangle: false, + output: { + comments: false, + beautify: true + } + } + })); +} + +const config = { + devtool: '#source-map', + + stats: { + children: false + }, + + watchOptions: { + ignored: /node_modules/ + }, + + entry: { + preload: 'preload.js', + vendor: 'vendor.js', + index: 'index.js' + }, + + resolve: { + modules: [ + root, + path.join(root, 'Shims'), + 'node_modules' + ], + alias: { + jquery: 'jquery/src/jquery' + } + }, + + output: { + filename: path.join('_output', uiFolder, '[name].js'), + sourceMapFilename: '[file].map' + }, + + plugins, + + resolveLoader: { + modules: [ + 'node_modules', + 'frontend/gulp/webpack/' + ] + }, + + module: { + rules: [ + { + test: /\.js?$/, + exclude: /(node_modules|JsLibraries)/, + loader: 'babel-loader', + query: { + plugins: ['transform-class-properties'], + presets: ['es2015', 'decorators-legacy', 'react', 'stage-2'], + env: { + development: { + plugins: ['transform-react-jsx-source'] + } + } + } + }, + + // CSS Modules + { + test: /\.css$/, + exclude: /(node_modules|globals.css)/, + use: extractCSSPlugin.extract({ + fallback: 'style-loader', + use: [ + { + loader: 'css-variables-loader', + options: { + cssVarsFiles + } + }, + { + loader: 'css-loader', + options: { + modules: true, + importLoaders: 1, + localIdentName: '[name]-[local]-[hash:base64:5]', + sourceMap: true + } + }, + { + loader: 'postcss-loader', + options: { + config: { + ctx: { + cssVarsFiles + }, + path: 'frontend/postcss.config.js' + } + } + } + ] + }) + }, + + // Global styles + { + test: /\.css$/, + include: /(node_modules|globals.css)/, + use: [ + 'style-loader', + { + loader: 'css-loader' + } + ] + }, + + // Fonts + { + test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10240, + mimetype: 'application/font-woff', + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + }, + + { + test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'file-loader', + options: { + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + } + ] + } +}; + +gulp.task('webpack', () => { + return gulp.src('index.js') + .pipe(webpackStream(config)) + .pipe(gulp.dest('')); +}); + +gulp.task('webpackWatch', () => { + config.watch = true; + return gulp.src('') + .pipe(webpackStream(config)) + .on('error', errorHandler) + .pipe(gulp.dest('')) + .on('error', errorHandler) + .pipe(livereload()) + .on('error', errorHandler); +}); diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/gulp/webpack/css-variables-loader.js new file mode 100644 index 000000000..5683c98be --- /dev/null +++ b/frontend/gulp/webpack/css-variables-loader.js @@ -0,0 +1,11 @@ +const loaderUtils = require('loader-utils'); + +module.exports = function cssVariablesLoader(source) { + const options = loaderUtils.getOptions(this); + + options.cssVarsFiles.forEach((cssVarsFile) => { + this.addDependency(cssVarsFile); + }); + + return source; +}; diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..f82554ba8 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,33 @@ +const reload = require('require-nocache')(module); + +module.exports = (ctx, configPath, options) => { + const config = { + plugins: { + 'postcss-mixins': { + mixinsDir: [ + 'frontend/src/Styles/Mixins' + ] + }, + 'postcss-simple-vars': { + variables: () => + ctx.options.cssVarsFiles.reduce((acc, vars) => { + return Object.assign(acc, reload(vars)); + }, {}) + }, + 'postcss-nested': {}, + autoprefixer: { + browsers: [ + 'Chrome >= 30', + 'Firefox >= 30', + 'Safari >= 6', + 'Edge >= 12', + 'Explorer >= 11', + 'iOS >= 7', + 'Android >= 4.4' + ] + } + } + }; + + return config; +}; diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json new file mode 100644 index 000000000..0fb2bf460 --- /dev/null +++ b/frontend/src/.vscode/settings.json @@ -0,0 +1,4 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.insertFinalNewline": true +} \ No newline at end of file diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js new file mode 100644 index 000000000..d93bec0bf --- /dev/null +++ b/frontend/src/Activity/Blacklist/Blacklist.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BlacklistRowConnector from './BlacklistRowConnector'; + +class Blacklist extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + totalRecords, + isClearingBlacklistExecuting, + onClearBlacklistPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load blacklist
+ } + + { + isPopulated && !error && !items.length && +
+ No history blacklist +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +Blacklist.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + onClearBlacklistPress: PropTypes.func.isRequired +}; + +export default Blacklist; diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js new file mode 100644 index 000000000..b182e7bb2 --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js @@ -0,0 +1,154 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as blacklistActions from 'Store/Actions/blacklistActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Blacklist from './Blacklist'; + +function createMapStateToProps() { + return createSelector( + (state) => state.blacklist, + createCommandExecutingSelector(commandNames.CLEAR_BLACKLIST), + (blacklist, isClearingBlacklistExecuting) => { + return { + isClearingBlacklistExecuting, + ...blacklist + }; + } + ); +} + +const mapDispatchToProps = { + ...blacklistActions, + executeCommand +}; + +class BlacklistConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchBlacklist, + gotoBlacklistFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchBlacklist(); + } else { + gotoBlacklistFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) { + this.props.gotoBlacklistFirstPage(); + } + } + + componentWillUnmount() { + this.props.clearBlacklist(); + unregisterPagePopulator(this.repopulate); + } + + // + // Control + + repopulate = () => { + this.props.fetchBlacklist(); + } + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoBlacklistFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoBlacklistPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoBlacklistNextPage(); + } + + onLastPagePress = () => { + this.props.gotoBlacklistLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoBlacklistPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setBlacklistSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setBlacklistTableOption(payload); + + if (payload.pageSize) { + this.props.gotoBlacklistFirstPage(); + } + } + + onClearBlacklistPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST }); + } + + onTableOptionChange = (payload) => { + this.props.setBlacklistTableOption(payload); + + if (payload.pageSize) { + this.props.gotoBlacklistFirstPage(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +BlacklistConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchBlacklist: PropTypes.func.isRequired, + gotoBlacklistFirstPage: PropTypes.func.isRequired, + gotoBlacklistPreviousPage: PropTypes.func.isRequired, + gotoBlacklistNextPage: PropTypes.func.isRequired, + gotoBlacklistLastPage: PropTypes.func.isRequired, + gotoBlacklistPage: PropTypes.func.isRequired, + setBlacklistSort: PropTypes.func.isRequired, + setBlacklistTableOption: PropTypes.func.isRequired, + clearBlacklist: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector) +); diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js new file mode 100644 index 000000000..356512a9d --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class BlacklistDetailsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + sourceTitle, + protocol, + indexer, + message, + onModalClose + } = this.props; + + return ( + + + + Details + + + + + + + + + { + !!message && + + } + + { + !!message && + + } + + + + + + + + + ); + } +} + +BlacklistDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default BlacklistDetailsModal; diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css new file mode 100644 index 000000000..b62d1e750 --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.css @@ -0,0 +1,18 @@ +.language, +.quality { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.indexer { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js new file mode 100644 index 000000000..39d4dbd0a --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import MovieQuality from 'Movie/MovieQuality'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import BlacklistDetailsModal from './BlacklistDetailsModal'; +import styles from './BlacklistRow.css'; + +class BlacklistRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + movie, + sourceTitle, + quality, + date, + protocol, + indexer, + message, + columns, + onRemovePress + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'movie.sortTitle') { + return ( + + + + ); + } + + if (name === 'sourceTitle') { + return ( + + {sourceTitle} + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +BlacklistRow.propTypes = { + id: PropTypes.number.isRequired, + movie: PropTypes.object.isRequired, + sourceTitle: PropTypes.string.isRequired, + quality: PropTypes.object.isRequired, + date: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default BlacklistRow; diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js new file mode 100644 index 000000000..275a02464 --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { removeFromBlacklist } from 'Store/Actions/blacklistActions'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import BlacklistRow from './BlacklistRow'; + +function createMapStateToProps() { + return createSelector( + createMovieSelector(), + (movie) => { + return { + movie + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRemovePress() { + dispatch(removeFromBlacklist({ id: props.id })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BlacklistRow); diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css new file mode 100644 index 000000000..03f8fd3ce --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css @@ -0,0 +1,5 @@ +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + overflow-wrap: break-word; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js new file mode 100644 index 000000000..6214eeb7e --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -0,0 +1,244 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import Link from 'Components/Link/Link'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import styles from './HistoryDetails.css'; + +function HistoryDetails(props) { + const { + eventType, + sourceTitle, + data, + shortDateFormat, + timeFormat + } = props; + + if (eventType === 'grabbed') { + const { + indexer, + releaseGroup, + nzbInfoUrl, + downloadClient, + downloadId, + age, + ageHours, + ageMinutes, + publishedDate + } = data; + + return ( + + + + { + !!indexer && + + } + + { + !!releaseGroup && + + } + + { + !!nzbInfoUrl && + + + Info URL + + + + {nzbInfoUrl} + + + } + + { + !!downloadClient && + + } + + { + !!downloadId && + + } + + { + !!indexer && + + } + + { + !!publishedDate && + + } + + ); + } + + if (eventType === 'downloadFailed') { + const { + message + } = data; + + return ( + + + + { + !!message && + + } + + ); + } + + if (eventType === 'downloadFolderImported') { + const { + droppedPath, + importedPath + } = data; + + return ( + + + + { + !!droppedPath && + + } + + { + !!importedPath && + + } + + ); + } + + if (eventType === 'episodeFileDeleted') { + const { + reason + } = data; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = 'File was deleted by via UI'; + break; + case 'MissingFromDisk': + reasonMessage = 'Radarr was unable to find the file on disk so it was removed'; + break; + case 'Upgrade': + reasonMessage = 'File was deleted to import an upgrade'; + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + ); + } + + if (eventType === 'episodeFileRenamed') { + const { + sourcePath, + sourceRelativePath, + path, + relativePath + } = data; + + return ( + + + + + + + + + + ); + } +} + +HistoryDetails.propTypes = { + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js new file mode 100644 index 000000000..0848c7905 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryDetails from './HistoryDetails'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'shortDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css b/frontend/src/Activity/History/Details/HistoryDetailsModal.css new file mode 100644 index 000000000..bdcb7f918 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css @@ -0,0 +1,5 @@ +.markAsFailedButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js new file mode 100644 index 000000000..2cf9294f6 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import HistoryDetails from './HistoryDetails'; +import styles from './HistoryDetailsModal.css'; + +function getHeaderTitle(eventType) { + switch (eventType) { + case 'grabbed': + return 'Grabbed'; + case 'downloadFailed': + return 'Download Failed'; + case 'downloadFolderImported': + return 'Episode Imported'; + case 'episodeFileDeleted': + return 'Episode File Deleted'; + case 'episodeFileRenamed': + return 'Episode File Renamed'; + default: + return 'Unknown'; + } +} + +function HistoryDetailsModal(props) { + const { + isOpen, + eventType, + sourceTitle, + data, + isMarkingAsFailed, + shortDateFormat, + timeFormat, + onMarkAsFailedPress, + onModalClose + } = props; + + return ( + + + + {getHeaderTitle(eventType)} + + + + + + + + { + eventType === 'grabbed' && + + Mark as Failed + + } + + + + + + ); +} + +HistoryDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +HistoryDetailsModal.defaultProps = { + isMarkingAsFailed: false +}; + +export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js new file mode 100644 index 000000000..2d18a5e6a --- /dev/null +++ b/frontend/src/Activity/History/History.js @@ -0,0 +1,163 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import HistoryRowConnector from './HistoryRowConnector'; + +class History extends Component { + + // + // Lifecycle + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before episodes start fetching or when episodes start fetching. + + if ( + ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) + ) + ) { + return false; + } + + return true; + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + onFilterSelect, + onFirstPagePress, + ...otherProps + } = this.props; + + const hasError = error; + + return ( + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && hasError && +
Unable to load history
+ } + + { + // If history isPopulated and it's empty show no history found and don't + // wait for the episodes to populate because they are never coming. + + isPopulated && !hasError && !items.length && +
+ No history found +
+ } + + { + isPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +History.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + onFilterSelect: PropTypes.func.isRequired, + onFirstPagePress: PropTypes.func.isRequired +}; + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js new file mode 100644 index 000000000..df780c1ed --- /dev/null +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -0,0 +1,134 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; +import * as historyActions from 'Store/Actions/historyActions'; +import History from './History'; + +function createMapStateToProps() { + return createSelector( + (state) => state.history, + (history) => { + return { + ...history + }; + } + ); +} + +const mapDispatchToProps = { + ...historyActions +}; + +class HistoryConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchHistory, + gotoHistoryFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchHistory(); + } else { + gotoHistoryFirstPage(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearHistory(); + } + + // + // Control + + repopulate = () => { + this.props.fetchHistory(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoHistoryFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoHistoryPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoHistoryNextPage(); + } + + onLastPagePress = () => { + this.props.gotoHistoryLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoHistoryPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setHistorySort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setHistoryFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setHistoryTableOption(payload); + + if (payload.pageSize) { + this.props.gotoHistoryFirstPage(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HistoryConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchHistory: PropTypes.func.isRequired, + gotoHistoryFirstPage: PropTypes.func.isRequired, + gotoHistoryPreviousPage: PropTypes.func.isRequired, + gotoHistoryNextPage: PropTypes.func.isRequired, + gotoHistoryLastPage: PropTypes.func.isRequired, + gotoHistoryPage: PropTypes.func.isRequired, + setHistorySort: PropTypes.func.isRequired, + setHistoryFilter: PropTypes.func.isRequired, + setHistoryTableOption: PropTypes.func.isRequired, + clearHistory: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) +); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css new file mode 100644 index 000000000..fac97a6c7 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.css @@ -0,0 +1,6 @@ +.cell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 35px; + text-align: center; +} diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js new file mode 100644 index 000000000..f013b3f55 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './HistoryEventTypeCell.css'; + +function getIconName(eventType) { + switch (eventType) { + case 'grabbed': + return icons.DOWNLOADING; + case 'seriesFolderImported': + return icons.DRIVE; + case 'downloadFolderImported': + return icons.DOWNLOADED; + case 'downloadFailed': + return icons.DOWNLOADING; + case 'episodeFileDeleted': + return icons.DELETE; + case 'episodeFileRenamed': + return icons.ORGANIZE; + default: + return icons.UNKNOWN; + } +} + +function getIconKind(eventType) { + switch (eventType) { + case 'downloadFailed': + return kinds.DANGER; + default: + return kinds.DEFAULT; + } +} + +function getTooltip(eventType, data) { + switch (eventType) { + case 'grabbed': + return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`; + case 'seriesFolderImported': + return 'Episode imported from series folder'; + case 'downloadFolderImported': + return 'Episode downloaded successfully and picked up from download client'; + case 'downloadFailed': + return 'Episode download failed'; + case 'episodeFileDeleted': + return 'Episode file deleted'; + case 'episodeFileRenamed': + return 'Episode file renamed'; + default: + return 'Unknown event'; + } +} + +function HistoryEventTypeCell({ eventType, data }) { + const iconName = getIconName(eventType); + const iconKind = getIconKind(eventType); + const tooltip = getTooltip(eventType, data); + + return ( + + + + ); +} + +HistoryEventTypeCell.propTypes = { + eventType: PropTypes.string.isRequired, + data: PropTypes.object +}; + +HistoryEventTypeCell.defaultProps = { + data: {} +}; + +export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryRow.css b/frontend/src/Activity/History/HistoryRow.css new file mode 100644 index 000000000..83586af58 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.css @@ -0,0 +1,23 @@ +.downloadClient { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 120px; +} + +.indexer { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.releaseGroup { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 110px; +} + +.details { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js new file mode 100644 index 000000000..3c9fba4f3 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.js @@ -0,0 +1,212 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import MovieQuality from 'Movie/MovieQuality'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; +import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import styles from './HistoryRow.css'; + +class HistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.setState({ isDetailsModalOpen: false }); + } + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + movie, + quality, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + isMarkingAsFailed, + columns, + shortDateFormat, + timeFormat, + onMarkAsFailedPress + } = this.props; + + if (!movie) { + return null; + } + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'movie.sortTitle') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'downloadClient') { + return ( + + {data.downloadClient} + + ); + } + + if (name === 'indexer') { + return ( + + {data.indexer} + + ); + } + + if (name === 'releaseGroup') { + return ( + + {data.releaseGroup} + + ); + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +HistoryRow.propTypes = { + movieId: PropTypes.number, + movie: PropTypes.object.isRequired, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js new file mode 100644 index 000000000..b68c0ba4c --- /dev/null +++ b/frontend/src/Activity/History/HistoryRowConnector.js @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryRow from './HistoryRow'; + +function createMapStateToProps() { + return createSelector( + createMovieSelector(), + createUISettingsSelector(), + (movie, uiSettings) => { + return { + movie, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +class HistoryRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.props.fetchHistory(); + } + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.props.markAsFailed({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } + +} + +HistoryRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + fetchHistory: PropTypes.func.isRequired, + markAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector); diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css new file mode 100644 index 000000000..15e8e4fc6 --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -0,0 +1,13 @@ +.torrent { + composes: label from 'Components/Label.css'; + + border-color: $torrentColor; + background-color: $torrentColor; +} + +.usenet { + composes: label from 'Components/Label.css'; + + border-color: $usenetColor; + background-color: $usenetColor; +} diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js new file mode 100644 index 000000000..e8a08943c --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import styles from './ProtocolLabel.css'; + +function ProtocolLabel({ protocol }) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ( + + ); +} + +ProtocolLabel.propTypes = { + protocol: PropTypes.string.isRequired +}; + +export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js new file mode 100644 index 000000000..b65cc8a0c --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.js @@ -0,0 +1,278 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import RemoveQueueItemsModal from './RemoveQueueItemsModal'; +import QueueOptionsConnector from './QueueOptionsConnector'; +import QueueRowConnector from './QueueRowConnector'; + +class Queue extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isPendingSelected: false, + isConfirmRemoveModalOpen: false + }; + } + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before episodes start fetching or when episodes start fetching. + + if ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) && + nextProps.items.some((e) => e.episodeId) + ) { + return false; + } + + return true; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + + return; + } + + const selectedIds = this.getSelectedIds(); + const isPendingSelected = _.some(this.props.items, (item) => { + return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay'; + }); + + if (isPendingSelected !== this.state.isPendingSelected) { + this.setState({ isPendingSelected }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onGrabSelectedPress = () => { + this.props.onGrabSelectedPress(this.getSelectedIds()); + } + + onRemoveSelectedPress = () => { + this.setState({ isConfirmRemoveModalOpen: true }); + } + + onRemoveSelectedConfirmed = (blacklist) => { + this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist); + this.setState({ isConfirmRemoveModalOpen: false }); + } + + onConfirmRemoveModalClose = () => { + this.setState({ isConfirmRemoveModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + totalRecords, + isGrabbing, + isRemoving, + isCheckForFinishedDownloadExecuting, + onRefreshPress, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmRemoveModalOpen, + isPendingSelected + } = this.state; + + const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting; + const isAllPopulated = isPopulated && !items.length; + const hasError = error; + const selectedCount = this.getSelectedIds().length; + const disableSelectedActions = selectedCount === 0; + + return ( + + + + + + + + + + + + + + + + + + + + + { + isRefreshing && !isAllPopulated && + + } + + { + !isRefreshing && hasError && +
+ Failed to load Queue +
+ } + + { + isPopulated && !hasError && !items.length && +
+ Queue is empty +
+ } + + { + isAllPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+ + +
+ ); + } +} + +Queue.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isGrabbing: PropTypes.bool.isRequired, + isRemoving: PropTypes.bool.isRequired, + isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onGrabSelectedPress: PropTypes.func.isRequired, + onRemoveSelectedPress: PropTypes.func.isRequired +}; + +export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js new file mode 100644 index 000000000..59bce00c3 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as queueActions from 'Store/Actions/queueActions'; +import * as commandNames from 'Commands/commandNames'; +import Queue from './Queue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.queue.options, + (state) => state.queue.paged, + createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), + (options, queue, isCheckForFinishedDownloadExecuting) => { + return { + isCheckForFinishedDownloadExecuting, + ...options, + ...queue + }; + } + ); +} + +const mapDispatchToProps = { + ...queueActions, + executeCommand +}; + +class QueueConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchQueue, + gotoQueueFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchQueue(); + } else { + gotoQueueFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if ( + this.props.includeUnknownMovieItems !== + prevProps.includeUnknownMovieItems + ) { + this.repopulate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearQueue(); + } + + // + // Control + + repopulate = () => { + this.props.fetchQueue(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoQueueFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoQueuePreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoQueueNextPage(); + } + + onLastPagePress = () => { + this.props.gotoQueueLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoQueuePage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setQueueSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setQueueTableOption(payload); + + if (payload.pageSize) { + this.props.gotoQueueFirstPage(); + } + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD + }); + } + + onGrabSelectedPress = (ids) => { + this.props.grabQueueItems({ ids }); + } + + onRemoveSelectedPress = (ids, blacklist) => { + this.props.removeQueueItems({ ids, blacklist }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueConnector.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchQueue: PropTypes.func.isRequired, + gotoQueueFirstPage: PropTypes.func.isRequired, + gotoQueuePreviousPage: PropTypes.func.isRequired, + gotoQueueNextPage: PropTypes.func.isRequired, + gotoQueueLastPage: PropTypes.func.isRequired, + gotoQueuePage: PropTypes.func.isRequired, + setQueueSort: PropTypes.func.isRequired, + setQueueTableOption: PropTypes.func.isRequired, + clearQueue: PropTypes.func.isRequired, + grabQueueItems: PropTypes.func.isRequired, + removeQueueItems: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) +); diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js new file mode 100644 index 000000000..f6e360c0a --- /dev/null +++ b/frontend/src/Activity/Queue/QueueDetails.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; + +function QueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status: queueStatus, + errorMessage, + progressBar + } = props; + + const status = queueStatus.toLowerCase(); + + const progress = (100 - sizeleft / size * 100); + + if (status === 'pending') { + return ( + + ); + } + + if (status === 'completed') { + if (errorMessage) { + return ( + + ); + } + + // TODO: show an icon when download is complete, but not imported yet? + } + + if (errorMessage) { + return ( + + ); + } + + if (status === 'failed') { + return ( + + ); + } + + if (status === 'warning') { + return ( + + ); + } + + if (progress < 5) { + return ( + + ); + } + + return progressBar; +} + +QueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string, + progressBar: PropTypes.node.isRequired +}; + +export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js new file mode 100644 index 000000000..900cf85cb --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class QueueOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + includeUnknownSeriesItems: props.includeUnknownSeriesItems + }; + } + + componentDidUpdate(prevProps) { + const { + includeUnknownSeriesItems + } = this.props; + + if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) { + this.setState({ + includeUnknownSeriesItems + }); + } + } + + // + // Listeners + + onOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onOptionChange({ + [name]: value + }); + }); + } + + // + // Render + + render() { + const { + includeUnknownSeriesItems + } = this.state; + + return ( + + + Show Unknown Series Items + + + + + ); + } +} + +QueueOptions.propTypes = { + includeUnknownSeriesItems: PropTypes.bool.isRequired, + onOptionChange: PropTypes.func.isRequired +}; + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js new file mode 100644 index 000000000..b2c99511c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setQueueOption } from 'Store/Actions/queueActions'; +import QueueOptions from './QueueOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.queue.options, + (options) => { + return options; + } + ); +} + +const mapDispatchToProps = { + onOptionChange: setQueueOption +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css new file mode 100644 index 000000000..6aa4a1622 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -0,0 +1,23 @@ +.quality { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.protocol { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.progress { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js new file mode 100644 index 000000000..8cc834fbe --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -0,0 +1,326 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import TableRow from 'Components/Table/TableRow'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import MovieQuality from 'Movie/MovieQuality'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import QueueStatusCell from './QueueStatusCell'; +import TimeleftCell from './TimeleftCell'; +import RemoveQueueItemModal from './RemoveQueueItemModal'; +import styles from './QueueRow.css'; + +class QueueRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRemoveQueueItemModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + // + // Listeners + + onRemoveQueueItemPress = () => { + this.setState({ isRemoveQueueItemModalOpen: true }); + } + + onRemoveQueueItemModalConfirmed = (blacklist) => { + this.props.onRemoveQueueItemPress(blacklist); + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onRemoveQueueItemModalClose = () => { + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + downloadId, + title, + status, + trackedDownloadStatus, + statusMessages, + errorMessage, + series, + episode, + quality, + protocol, + indexer, + downloadClient, + estimatedCompletionTime, + timeleft, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat, + isGrabbing, + grabError, + isRemoving, + isSelected, + columns, + onSelectedChange, + onGrabPress + } = this.props; + + const { + isRemoveQueueItemModalOpen, + isInteractiveImportModalOpen + } = this.state; + + const progress = 100 - (sizeleft / size * 100); + const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning'; + const isPending = status === 'Delay' || status === 'DownloadClientUnavailable'; + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'series') { + return ( + + + + ); + } + + if (name === 'episode.airDateUtc') { + return ( + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'downloadClient') { + return ( + + {downloadClient} + + ); + } + + if (name === 'estimatedCompletionTime') { + return ( + + ); + } + + if (name === 'progress') { + return ( + + { + !!progress && + + } + + ); + } + + if (name === 'actions') { + return ( + + { + showInteractiveImport && + + } + + { + isPending && + + } + + + + ); + } + + return null; + }) + } + + + + + + ); + } + +} + +QueueRow.propTypes = { + id: PropTypes.number.isRequired, + downloadId: PropTypes.string, + title: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string, + series: PropTypes.object.isRequired, + episode: PropTypes.object.isRequired, + quality: PropTypes.object.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + downloadClient: PropTypes.string, + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + size: PropTypes.number, + sizeleft: PropTypes.number, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isGrabbing: PropTypes.bool.isRequired, + grabError: PropTypes.object, + isRemoving: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired, + onRemoveQueueItemPress: PropTypes.func.isRequired +}; + +QueueRow.defaultProps = { + isGrabbing: false, + isRemoving: false +}; + +export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js new file mode 100644 index 000000000..3fdeb2b22 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -0,0 +1,68 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueueRow from './QueueRow'; + +function createMapStateToProps() { + return createSelector( + createMovieSelector(), + createUISettingsSelector(), + (series, uiSettings) => { + const result = _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'timeFormat' + ]); + + result.series = series; + + return result; + } + ); +} + +const mapDispatchToProps = { + grabQueueItem, + removeQueueItem +}; + +class QueueRowConnector extends Component { + + // + // Listeners + + onGrabPress = () => { + this.props.grabQueueItem({ id: this.props.id }); + } + + onRemoveQueueItemPress = (blacklist) => { + this.props.removeQueueItem({ id: this.props.id, blacklist }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueRowConnector.propTypes = { + id: PropTypes.number.isRequired, + episode: PropTypes.object, + grabQueueItem: PropTypes.func.isRequired, + removeQueueItem: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector); diff --git a/frontend/src/Activity/Queue/QueueStatusCell.css b/frontend/src/Activity/Queue/QueueStatusCell.css new file mode 100644 index 000000000..6291ec949 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.css @@ -0,0 +1,5 @@ +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js new file mode 100644 index 000000000..f8cbc65ff --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import styles from './QueueStatusCell.css'; + +function getDetailedPopoverBody(statusMessages) { + return ( +
+ { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + { + messages.map((message) => { + return ( +
  • + {message} +
  • + ); + }) + } +
+
+ ); + }) + } +
+ ); +} + +function QueueStatusCell(props) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'Ok', + statusMessages, + errorMessage + } = props; + + const hasWarning = trackedDownloadStatus === 'Warning'; + const hasError = trackedDownloadStatus === 'Error'; + + // status === 'downloading' + let iconName = icons.DOWNLOADING; + let iconKind = kinds.DEFAULT; + let title = 'Downloading'; + + if (hasWarning) { + iconKind = kinds.WARNING; + } + + if (status === 'Paused') { + iconName = icons.PAUSED; + title = 'Paused'; + } + + if (status === 'Queued') { + iconName = icons.QUEUED; + title = 'Queued'; + } + + if (status === 'Completed') { + iconName = icons.DOWNLOADED; + title = 'Downloaded'; + } + + if (status === 'Delay') { + iconName = icons.PENDING; + title = 'Pending'; + } + + if (status === 'DownloadClientUnavailable') { + iconName = icons.PENDING; + iconKind = kinds.WARNING; + title = 'Pending - Download client is unavailable'; + } + + if (status === 'Failed') { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + + if (status === 'Warning') { + iconName = icons.DOWNLOADING; + iconKind = kinds.WARNING; + title = `Download warning: ${errorMessage || 'check download client for more details'}`; + } + + if (hasError) { + if (status === 'Completed') { + iconName = icons.DOWNLOAD; + iconKind = kinds.DANGER; + title = `Import failed: ${sourceTitle}`; + } else { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + } + + return ( + + + } + title={title} + body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} + position={tooltipPositions.RIGHT} + /> + + ); +} + +QueueStatusCell.propTypes = { + sourceTitle: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string +}; + +export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css new file mode 100644 index 000000000..c9ef59ec1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css @@ -0,0 +1,3 @@ +.message { + margin-bottom: 30px; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js new file mode 100644 index 000000000..6c208ebbb --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemModal.css'; + +class RemoveQueueItemModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + + this.setState({ blacklist: false }); + this.props.onRemovePress(blacklist); + } + + onModalClose = () => { + this.setState({ blacklist: false }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + sourceTitle + } = this.props; + + const blacklist = this.state.blacklist; + + return ( + + + + Remove - {sourceTitle} + + + +
+ Are you sure you want to remove '{sourceTitle}' from the queue? +
+ + + Blacklist Release + + + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css new file mode 100644 index 000000000..c9ef59ec1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css @@ -0,0 +1,3 @@ +.message { + margin-bottom: 30px; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js new file mode 100644 index 000000000..d56472efb --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemsModal.css'; + +class RemoveQueueItemsModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + + this.setState({ blacklist: false }); + this.props.onRemovePress(blacklist); + } + + onModalClose = () => { + this.setState({ blacklist: false }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + selectedCount + } = this.props; + + const blacklist = this.state.blacklist; + + return ( + + + + Remove Selected Item{selectedCount > 1 ? 's' : ''} + + + +
+ Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue? +
+ + + Blacklist Release + + + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + selectedCount: PropTypes.number.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js new file mode 100644 index 000000000..ead2bfcfa --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.queue.status, + (state) => state.queue.options.includeUnknownMovieItems, + (app, status, includeUnknownMovieItems) => { + const { + count, + unknownCount + } = status.item; + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: status.isPopulated, + ...status.item, + count: includeUnknownMovieItems ? count : count - unknownCount + }; + } + ); +} + +const mapDispatchToProps = { + fetchQueueStatus +}; + +class QueueStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchQueueStatus(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchQueueStatus(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchQueueStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector); diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css new file mode 100644 index 000000000..eb58cf297 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.css @@ -0,0 +1,5 @@ +.timeleft { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js new file mode 100644 index 000000000..c9515f172 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatTime from 'Utilities/Date/formatTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './TimeleftCell.css'; + +function TimeleftCell(props) { + const { + estimatedCompletionTime, + timeleft, + status, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (status === 'Delay') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (status === 'DownloadClientUnavailable') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (!timeleft) { + return ( + + - + + ); + } + + const totalSize = formatBytes(size); + const remainingSize = formatBytes(sizeleft); + + return ( + + {formatTimeSpan(timeleft)} + + ); +} + +TimeleftCell.propTypes = { + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + status: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default TimeleftCell; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.css b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.css new file mode 100644 index 000000000..0bf8b0e15 --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.css @@ -0,0 +1,54 @@ +.searchContainer { + display: flex; + margin-bottom: 10px; +} + +.searchIconContainer { + width: 58px; + height: 46px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 46px; +} + +.searchInput { + composes: input from 'Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearLookupButton { + border: 1px solid $inputBorderColor; + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; +} + +.helpText { + margin-bottom: 10px; + font-weight: 300; + font-size: 24px; +} + +.noResults { + margin-bottom: 10px; + font-weight: 300; + font-size: 30px; +} + +.searchResults { + margin-top: 30px; +} diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js new file mode 100644 index 000000000..e08ad2f0d --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js @@ -0,0 +1,182 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import AddNewMovieSearchResultConnector from './AddNewMovieSearchResultConnector'; +import styles from './AddNewMovie.css'; + +class AddNewMovie extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + term: props.term || '', + isFetching: false + }; + } + + componentDidMount() { + const term = this.state.term; + + if (term) { + this.props.onMovieLookupChange(term); + } + } + + componentDidUpdate(prevProps) { + const { + term, + isFetching + } = this.props; + + if (term && term !== prevProps.term) { + this.setState({ + term, + isFetching: true + }); + this.props.onMovieLookupChange(term); + } else if (isFetching !== prevProps.isFetching) { + this.setState({ + isFetching + }); + } + } + + // + // Listeners + + onSearchInputChange = ({ value }) => { + const hasValue = !!value.trim(); + + this.setState({ term: value, isFetching: hasValue }, () => { + if (hasValue) { + this.props.onMovieLookupChange(value); + } else { + this.props.onClearMovieLookup(); + } + }); + } + + onClearMovieLookupPress = () => { + this.setState({ term: '' }); + this.props.onClearMovieLookup(); + } + + // + // Render + + render() { + const { + error, + items + } = this.props; + + const term = this.state.term; + const isFetching = this.state.isFetching; + + return ( + + +
+
+ +
+ + + + +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
Failed to load search results, please try again.
+ } + + { + !isFetching && !error && !!items.length && +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+ } + + { + !isFetching && !error && !items.length && !!term && +
+
Couldn't find any results for '{term}'
+
You can also search using TMDB ID or IMDB ID of a movie. eg. tmdb:71663
+
+ + Why can't I find my movie? + +
+
+ } + + { + !term && +
+
It's easy to add a new movie, just start typing the name the movie you want to add.
+
You can also search using TMDB ID of a movie. eg. tmdb:71663
+
+ } + +
+ + + ); + } +} + +AddNewMovie.propTypes = { + term: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMovieLookupChange: PropTypes.func.isRequired, + onClearMovieLookup: PropTypes.func.isRequired +}; + +export default AddNewMovie; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js new file mode 100644 index 000000000..e4116a653 --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import parseUrl from 'Utilities/String/parseUrl'; +import { lookupMovie, clearAddMovie } from 'Store/Actions/addMovieActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import AddNewMovie from './AddNewMovie'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addMovie, + (state) => state.routing.location, + (addMovie, location) => { + const { params } = parseUrl(location.search); + + return { + term: params.term, + ...addMovie + }; + } + ); +} + +const mapDispatchToProps = { + lookupMovie, + clearAddMovie, + fetchRootFolders +}; + +class AddNewMovieConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._movieLookupTimeout = null; + } + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentWillUnmount() { + if (this._movieLookupTimeout) { + clearTimeout(this._movieLookupTimeout); + } + + this.props.clearAddMovie(); + } + + // + // Listeners + + onMovieLookupChange = (term) => { + if (this._movieLookupTimeout) { + clearTimeout(this._movieLookupTimeout); + } + + if (term.trim() === '') { + this.props.clearAddMovie(); + } else { + this._movieLookupTimeout = setTimeout(() => { + this.props.lookupMovie({ term }); + }, 300); + } + } + + onClearMovieLookup = () => { + this.props.clearAddMovie(); + } + + // + // Render + + render() { + const { + term, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AddNewMovieConnector.propTypes = { + term: PropTypes.string, + lookupMovie: PropTypes.func.isRequired, + clearAddMovie: PropTypes.func.isRequired, + fetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewMovieConnector); diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModal.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModal.js new file mode 100644 index 000000000..785f758ab --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddNewMovieModalContentConnector from './AddNewMovieModalContentConnector'; + +function AddNewMovieModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +AddNewMovieModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNewMovieModal; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.css b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.css new file mode 100644 index 000000000..d1cffc210 --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.css @@ -0,0 +1,68 @@ +.container { + display: flex; +} + +.year { + margin-left: 5px; + color: $disabledColor; +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.info { + flex-grow: 1; +} + +.overview { + margin-bottom: 30px; +} + +.labelIcon { + margin-left: 8px; +} + +.searchForMissingEpisodesLabelContainer { + display: flex; + margin-top: 2px; +} + +.searchForMissingEpisodesLabel { + margin-right: 8px; + font-weight: normal; +} + +.searchForMissingEpisodesContainer { + composes: container from 'Components/Form/CheckInput.css'; + + flex: 0 1 0; +} + +.searchForMissingEpisodesInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 0; +} + +.modalFooter { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; +} + +.addButton { + @add-mixin truncate; + composes: button from 'Components/Link/SpinnerButton.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + display: block; + text-align: center; + } + + .addButton { + margin-top: 10px; + } +} diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js new file mode 100644 index 000000000..8e981bfe5 --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContent.js @@ -0,0 +1,190 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds, inputTypes } from 'Helpers/Props'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import CheckInput from 'Components/Form/CheckInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import MoviePoster from 'Movie/MoviePoster'; +import styles from './AddNewMovieModalContent.css'; + +class AddNewMovieModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + searchForMissingEpisodes: false + }; + } + + // + // Listeners + + onSearchForMissingEpisodesChange = ({ value }) => { + this.setState({ searchForMissingEpisodes: value }); + } + + onQualityProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) }); + } + + onAddMoviePress = () => { + this.props.onAddMoviePress(this.state.searchForMissingEpisodes); + } + + // + // Render + + render() { + const { + title, + year, + overview, + images, + isAdding, + rootFolderPath, + monitor, + qualityProfileId, + tags, + isSmallScreen, + onModalClose, + onInputChange + } = this.props; + + return ( + + + {title} + + { + !title.contains(year) && !!year && + ({year}) + } + + + +
+ { + !isSmallScreen && +
+ +
+ } + +
+
+ {overview} +
+ +
+ + Root Folder + + + + + + + Monitor + + + + + + + Quality Profile + + + + + + Tags + + + +
+
+
+
+ + + + + + Add {title} + + +
+ ); + } +} + +AddNewMovieModalContent.propTypes = { + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + overview: PropTypes.string, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + tags: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, + onAddMoviePress: PropTypes.func.isRequired +}; + +export default AddNewMovieModalContent; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContentConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContentConnector.js new file mode 100644 index 000000000..5f97abbda --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieModalContentConnector.js @@ -0,0 +1,97 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAddMovieDefault, addMovie } from 'Store/Actions/addMovieActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import AddNewMovieModalContent from './AddNewMovieModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addMovie, + createDimensionsSelector(), + (addMovieState, dimensions) => { + const { + isAdding, + addError, + defaults + } = addMovieState; + + const { + settings, + validationErrors, + validationWarnings + } = selectSettings(defaults, {}, addError); + + return { + isAdding, + addError, + isSmallScreen: dimensions.isSmallScreen, + validationErrors, + validationWarnings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setAddMovieDefault, + addMovie +}; + +class AddNewMovieModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAddMovieDefault({ [name]: value }); + } + + onAddMoviePress = (searchForMissingEpisodes) => { + const { + tmdbId, + rootFolderPath, + monitor, + qualityProfileId, + tags + } = this.props; + + this.props.addMovie({ + tmdbId, + rootFolderPath: rootFolderPath.value, + monitor: monitor.value, + qualityProfileId: qualityProfileId.value, + tags: tags.value, + searchForMissingEpisodes + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNewMovieModalContentConnector.propTypes = { + tmdbId: PropTypes.number.isRequired, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + tags: PropTypes.object.isRequired, + onModalClose: PropTypes.func.isRequired, + setAddMovieDefault: PropTypes.func.isRequired, + addMovie: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewMovieModalContentConnector); diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.css b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.css new file mode 100644 index 000000000..38ccffb4d --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.css @@ -0,0 +1,40 @@ +.searchResult { + display: flex; + margin: 20px 0; + padding: 20px; + width: 100%; + background-color: $white; + color: inherit; + transition: background 500ms; + + &:hover { + background-color: #eaf2ff; + color: inherit; + text-decoration: none; + } +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.title { + font-weight: 300; + font-size: 36px; +} + +.year { + margin-left: 10px; + color: $disabledColor; +} + +.alreadyExistsIcon { + margin-left: 10px; + color: #37bc9b; +} + +.overview { + margin-top: 20px; +} diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js new file mode 100644 index 000000000..186459e95 --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js @@ -0,0 +1,162 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import MoviePoster from 'Movie/MoviePoster'; +import AddNewMovieModal from './AddNewMovieModal'; +import styles from './AddNewMovieSearchResult.css'; + +class AddNewMovieSearchResult extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNewAddMovieModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (!prevProps.isExistingMovie && this.props.isExistingMovie) { + this.onAddSerisModalClose(); + } + } + + // + // Listeners + + onPress = () => { + this.setState({ isNewAddMovieModalOpen: true }); + } + + onAddSerisModalClose = () => { + this.setState({ isNewAddMovieModalOpen: false }); + } + + // + // Render + + render() { + const { + tmdbId, + title, + titleSlug, + year, + studio, + status, + overview, + ratings, + images, + isExistingMovie, + isSmallScreen + } = this.props; + const { + isNewAddMovieModalOpen + } = this.state; + + const linkProps = isExistingMovie ? { to: `/movie/${titleSlug}` } : { onPress: this.onPress }; + + return ( +
+ + { + isSmallScreen ? + null : + + } + +
+
+ {title} + + { + !title.contains(year) && !!year && + ({year}) + } + + { + isExistingMovie && + + } +
+ +
+ + + { + !!studio && + + } + + { + status === 'ended' && + + } +
+ +
+ {overview} +
+
+ + + +
+ ); + } +} + +AddNewMovieSearchResult.propTypes = { + tmdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + studio: PropTypes.string, + status: PropTypes.string.isRequired, + overview: PropTypes.string, + ratings: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isExistingMovie: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired +}; + +export default AddNewMovieSearchResult; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js new file mode 100644 index 000000000..fccbe6120 --- /dev/null +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingMovieSelector from 'Store/Selectors/createExistingMovieSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import AddNewMovieSearchResult from './AddNewMovieSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingMovieSelector(), + createDimensionsSelector(), + (isExistingMovie, dimensions) => { + return { + isExistingMovie, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(AddNewMovieSearchResult); diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js new file mode 100644 index 000000000..b4617604b --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js @@ -0,0 +1,169 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ImportMovieTableConnector from './ImportMovieTableConnector'; +import ImportMovieFooterConnector from './ImportMovieFooterConnector'; + +class ImportMovie extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + contentBody: null, + scrollTop: 0 + }; + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + // + // Listeners + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState, { parseIds: false }); + } + + onSelectAllChange = ({ value }) => { + // Only select non-dupes + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onRemoveSelectedStateItem = (id) => { + this.setState((state) => { + const selectedState = Object.assign({}, state.selectedState); + delete selectedState[id]; + + return { + ...state, + selectedState + }; + }); + } + + onInputChange = ({ name, value }) => { + this.props.onInputChange(this.getSelectedIds(), name, value); + } + + onImportPress = () => { + this.props.onImportPress(this.getSelectedIds()); + } + + onScroll = ({ scrollTop }) => { + this.setState({ scrollTop }); + } + + // + // Render + + render() { + const { + rootFolderId, + path, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + unmappedFolders + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + contentBody + } = this.state; + + return ( + + + { + rootFoldersFetching && !rootFoldersPopulated && + + } + + { + !rootFoldersFetching && !!rootFoldersError && +
Unable to load root folders
+ } + + { + !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length && +
+ All series in {path} have been imported +
+ } + + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody && + + } +
+ + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && + + } +
+ ); + } +} + +ImportMovie.propTypes = { + rootFolderId: PropTypes.number.isRequired, + path: PropTypes.string, + rootFoldersFetching: PropTypes.bool.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + rootFoldersError: PropTypes.object, + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + items: PropTypes.arrayOf(PropTypes.object), + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired +}; + +ImportMovie.defaultProps = { + unmappedFolders: [] +}; + +export default ImportMovie; diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js new file mode 100644 index 000000000..5b2000cc1 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js @@ -0,0 +1,152 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setImportMovieValue, importMovie, clearImportMovie } from 'Store/Actions/importMovieActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { setAddMovieDefault } from 'Store/Actions/addMovieActions'; +import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; +import ImportMovie from './ImportMovie'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + (state) => state.rootFolders, + (state) => state.addMovie, + (state) => state.importMovie, + (state) => state.settings.qualityProfiles, + ( + match, + rootFolders, + addMovie, + importMovieState, + qualityProfiles, + ) => { + const { + isFetching: rootFoldersFetching, + isPopulated: rootFoldersPopulated, + error: rootFoldersError, + items + } = rootFolders; + + const rootFolderId = parseInt(match.params.rootFolderId); + + const result = { + rootFolderId, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + qualityProfiles: qualityProfiles.items, + defaultQualityProfileId: addMovie.defaults.qualityProfileId + }; + + if (items.length) { + const rootFolder = _.find(items, { id: rootFolderId }); + + return { + ...result, + ...rootFolder, + items: importMovieState.items + }; + } + + return result; + } + ); +} + +const mapDispatchToProps = { + dispatchSetImportMovieValue: setImportMovieValue, + dispatchImportMovie: importMovie, + dispatchClearImportMovie: clearImportMovie, + dispatchFetchRootFolders: fetchRootFolders, + dispatchSetAddSeriesDefault: setAddMovieDefault +}; + +class ImportMovieConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + qualityProfiles, + defaultQualityProfileId, + dispatchFetchRootFolders, + dispatchSetAddSeriesDefault + } = this.props; + + if (!this.props.rootFoldersPopulated) { + dispatchFetchRootFolders(); + } + + let setDefaults = false; + const setDefaultPayload = {}; + + if ( + !defaultQualityProfileId || + !qualityProfiles.some((p) => p.id === defaultQualityProfileId) + ) { + setDefaults = true; + setDefaultPayload.qualityProfileId = qualityProfiles[0].id; + } + + if (setDefaults) { + dispatchSetAddSeriesDefault(setDefaultPayload); + } + } + + componentWillUnmount() { + this.props.dispatchClearImportMovie(); + } + + // + // Listeners + + onInputChange = (ids, name, value) => { + this.props.dispatchSetAddSeriesDefault({ [name]: value }); + + ids.forEach((id) => { + this.props.dispatchSetImportMovieValue({ + id, + [name]: value + }); + }); + } + + onImportPress = (ids) => { + this.props.dispatchImportMovie({ ids }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +const routeMatchShape = createRouteMatchShape({ + rootFolderId: PropTypes.string.isRequired +}); + +ImportMovieConnector.propTypes = { + match: routeMatchShape.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + defaultQualityProfileId: PropTypes.number.isRequired, + dispatchSetImportMovieValue: PropTypes.func.isRequired, + dispatchImportMovie: PropTypes.func.isRequired, + dispatchClearImportMovie: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchSetAddSeriesDefault: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieConnector); diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css new file mode 100644 index 000000000..0a61ca509 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css @@ -0,0 +1,33 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.importButtonContainer { + display: flex; + align-items: center; +} + +.importButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + height: 35px; +} + +.loadingButton { + composes: importButton; + + margin-left: 10px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin: 0 10px 0 12px; + text-align: left; +} diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js new file mode 100644 index 000000000..4c7eb7352 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.js @@ -0,0 +1,184 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +// import CheckInput from 'Components/Form/CheckInput'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './ImportMovieFooter.css'; + +const MIXED = 'mixed'; + +class ImportMovieFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + defaultMonitor, + defaultQualityProfileId + } = props; + + this.state = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + defaultMonitor, + defaultQualityProfileId, + isMonitorMixed, + isQualityProfileIdMixed + } = this.props; + + const { + monitor, + qualityProfileId + } = this.state; + + const newState = {}; + + if (isMonitorMixed && monitor !== MIXED) { + newState.monitor = MIXED; + } else if (!isMonitorMixed && monitor !== defaultMonitor) { + newState.monitor = defaultMonitor; + } + + if (isQualityProfileIdMixed && qualityProfileId !== MIXED) { + newState.qualityProfileId = MIXED; + } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) { + newState.qualityProfileId = defaultQualityProfileId; + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + this.props.onInputChange({ name, value }); + } + + // + // Render + + render() { + const { + selectedCount, + isImporting, + isLookingUpMovie, + isMonitorMixed, + isQualityProfileIdMixed, + onImportPress, + onCancelLookupPress + } = this.props; + + const { + monitor, + qualityProfileId + } = this.state; + + return ( + +
+
+ Monitor +
+ + +
+ +
+
+ Quality Profile +
+ + +
+ +
+
+   +
+ +
+ + Import {selectedCount} {selectedCount > 1 ? 'Movies' : 'Movie'} + + + { + isLookingUpMovie && + + } + + { + isLookingUpMovie && + + } + + { + isLookingUpMovie && + 'Processing Folders' + } +
+
+
+ ); + } +} + +ImportMovieFooter.propTypes = { + selectedCount: PropTypes.number.isRequired, + isImporting: PropTypes.bool.isRequired, + isLookingUpMovie: PropTypes.bool.isRequired, + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + isMonitorMixed: PropTypes.bool.isRequired, + isQualityProfileIdMixed: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired, + onCancelLookupPress: PropTypes.func.isRequired +}; + +export default ImportMovieFooter; diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooterConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooterConnector.js new file mode 100644 index 000000000..28cb4ea59 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooterConnector.js @@ -0,0 +1,50 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelLookupMovie } from 'Store/Actions/importMovieActions'; +import ImportMovieFooter from './ImportMovieFooter'; + +function isMixed(items, selectedIds, defaultValue, key) { + return _.some(items, (series) => { + return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue; + }); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.addMovie, + (state) => state.importMovie, + (state, { selectedIds }) => selectedIds, + (addMovie, importMovie, selectedIds) => { + const { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId + } = addMovie.defaults; + + const { + isLookingUpMovie, + isImporting, + items + } = importMovie; + + const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); + const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); + + return { + selectedCount: selectedIds.length, + isLookingUpMovie, + isImporting, + defaultMonitor, + defaultQualityProfileId, + isMonitorMixed, + isQualityProfileIdMixed + }; + } + ); +} + +const mapDispatchToProps = { + onCancelLookupPress: cancelLookupMovie +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieFooter); diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.css new file mode 100644 index 000000000..a80a35890 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.css @@ -0,0 +1,30 @@ +.folder { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 200px; +} + +.monitor { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.movie { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} + +.detailsIcon { + margin-left: 8px; +} diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.js new file mode 100644 index 000000000..7b59b409f --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieHeader.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; +import styles from './ImportMovieHeader.css'; + +function ImportMovieHeader(props) { + const { + allSelected, + allUnselected, + onSelectAllChange + } = props; + + return ( + + + + + Folder + + + + Monitor + + + + Quality Profile + + + + Movie + + + ); +} + +ImportMovieHeader.propTypes = { + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default ImportMovieHeader; diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.css new file mode 100644 index 000000000..5dd1c9fef --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.css @@ -0,0 +1,31 @@ +.selectInput { + composes: input from 'Components/Form/CheckInput.css'; +} + +.folder { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 1 0 200px; + line-height: 36px; +} + +.monitor { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.series { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.js new file mode 100644 index 000000000..0839432ff --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRow.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import ImportMovieSelectMovieConnector from './SelectMovie/ImportMovieSelectMovieConnector'; +import styles from './ImportMovieRow.css'; + +function ImportMovieRow(props) { + const { + style, + id, + monitor, + qualityProfileId, + selectedMovie, + isExistingMovie, + isSelected, + onSelectedChange, + onInputChange + } = props; + + return ( + + + + + {id} + + + + + + + + + + + + + + + ); +} + +ImportMovieRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string.isRequired, + qualityProfileId: PropTypes.number.isRequired, + selectedMovie: PropTypes.object, + isExistingMovie: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + queued: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +ImportMovieRow.defaultsProps = { + items: [] +}; + +export default ImportMovieRow; diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRowConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRowConnector.js new file mode 100644 index 000000000..b134b8ffd --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieRowConnector.js @@ -0,0 +1,87 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupMovie, setImportMovieValue } from 'Store/Actions/importMovieActions'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import ImportMovieRow from './ImportMovieRow'; + +function createImportMovieItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.importMovie.items, + (id, items) => { + return _.find(items, { id }) || {}; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createImportMovieItemSelector(), + createAllMoviesSelector(), + (item, movies) => { + const selectedMovie = item && item.selectedMovie; + const isExistingMovie = !!selectedMovie && _.some(movies, { tmdbId: selectedMovie.tmdbId }); + + return { + ...item, + isExistingMovie + }; + } + ); +} + +const mapDispatchToProps = { + queueLookupMovie, + setImportMovieValue +}; + +class ImportMovieRowConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setImportMovieValue({ + id: this.props.id, + [name]: value + }); + } + + // + // Render + + render() { + // Don't show the row until we have the information we require for it. + + const { + items, + monitor + } = this.props; + + if (!items || !monitor) { + return null; + } + + return ( + + ); + } +} + +ImportMovieRowConnector.propTypes = { + rootFolderId: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.object), + queueLookupMovie: PropTypes.func.isRequired, + setImportMovieValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieRowConnector); diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieSelected.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieSelected.css new file mode 100644 index 000000000..efc6dccb3 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieSelected.css @@ -0,0 +1,3 @@ +.input { + composes: input from 'Components/Form/CheckInput.css'; +} diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTable.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTable.js new file mode 100644 index 000000000..eb886d0e4 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTable.js @@ -0,0 +1,183 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import VirtualTable from 'Components/Table/VirtualTable'; +import ImportMovieHeader from './ImportMovieHeader'; +import ImportMovieRowConnector from './ImportMovieRowConnector'; + +class ImportMovieTable extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + unmappedFolders, + defaultMonitor, + defaultQualityProfileId, + onMovieLookup, + onSetImportMovieValue + } = this.props; + + const values = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId + }; + + unmappedFolders.forEach((unmappedFolder) => { + const id = unmappedFolder.name; + + onMovieLookup(id, unmappedFolder.path); + + onSetImportMovieValue({ + id, + ...values + }); + }); + } + + // This isn't great, but it's the most reliable way to ensure the items + // are checked off even if they aren't actually visible since the cells + // are virtualized. + + componentDidUpdate(prevProps) { + const { + items, + selectedState, + onSelectedChange, + onRemoveSelectedStateItem + } = this.props; + + prevProps.items.forEach((prevItem) => { + const { + id + } = prevItem; + + const item = _.find(items, { id }); + + if (!item) { + onRemoveSelectedStateItem(id); + return; + } + + const selectedMovie = item.selectedMovie; + const isSelected = selectedState[id]; + + const isExistingMovie = !!selectedMovie && + _.some(prevProps.allMovies, { tmdbId: selectedMovie.tmdbId }); + + // Props doesn't have a selected series or + // the selected series is an existing series. + if ((!selectedMovie && prevItem.selectedMovie) || (isExistingMovie && !prevItem.selectedMovie)) { + onSelectedChange({ id, value: false }); + + return; + } + + // State is selected, but a series isn't selected or + // the selected series is an existing series. + if (isSelected && (!selectedMovie || isExistingMovie)) { + onSelectedChange({ id, value: false }); + + return; + } + + // A series is being selected that wasn't previously selected. + if (selectedMovie && selectedMovie !== prevItem.selectedMovie) { + onSelectedChange({ id, value: true }); + + return; + } + }); + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + rootFolderId, + items, + selectedState, + onSelectedChange + } = this.props; + + const item = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + allSelected, + allUnselected, + isSmallScreen, + contentBody, + scrollTop, + selectedState, + onSelectAllChange, + onScroll + } = this.props; + + if (!items.length) { + return null; + } + + return ( + + } + selectedState={selectedState} + onScroll={onScroll} + /> + ); + } +} + +ImportMovieTable.propTypes = { + rootFolderId: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + selectedState: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + allMovies: PropTypes.arrayOf(PropTypes.object), + contentBody: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + onSelectAllChange: PropTypes.func.isRequired, + onSelectedChange: PropTypes.func.isRequired, + onRemoveSelectedStateItem: PropTypes.func.isRequired, + onMovieLookup: PropTypes.func.isRequired, + onSetImportMovieValue: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ImportMovieTable; diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTableConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTableConnector.js new file mode 100644 index 000000000..42499c703 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieTableConnector.js @@ -0,0 +1,41 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupMovie, setImportMovieValue } from 'Store/Actions/importMovieActions'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import ImportMovieTable from './ImportMovieTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addMovie, + (state) => state.importMovie, + (state) => state.app.dimensions, + createAllMoviesSelector(), + (addMovie, importMovie, dimensions, allMovies) => { + return { + defaultMonitor: addMovie.defaults.monitor, + defaultQualityProfileId: addMovie.defaults.qualityProfileId, + items: importMovie.items, + isSmallScreen: dimensions.isSmallScreen, + allMovies + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onMovieLookup(name, path) { + dispatch(queueLookupMovie({ + name, + path, + term: name + })); + }, + + onSetImportMovieValue(values) { + dispatch(setImportMovieValue(values)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ImportMovieTable); diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.css b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.css new file mode 100644 index 000000000..a862c117c --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.css @@ -0,0 +1,8 @@ +.series { + padding: 10px 20px; + width: 100%; + + &:hover { + background-color: $menuItemHoverBackgroundColor; + } +} diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.js new file mode 100644 index 000000000..0545df7cc --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResult.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import ImportMovieTitle from './ImportMovieTitle'; +import styles from './ImportMovieSearchResult.css'; + +class ImportMovieSearchResult extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.tmdbId); + } + + // + // Render + + render() { + const { + title, + year, + studio, + isExistingMovie + } = this.props; + + return ( + + + + ); + } +} + +ImportMovieSearchResult.propTypes = { + tmdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + studio: PropTypes.string, + isExistingMovie: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default ImportMovieSearchResult; diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResultConnector.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResultConnector.js new file mode 100644 index 000000000..da5608403 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSearchResultConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingMovieSelector from 'Store/Selectors/createExistingMovieSelector'; +import ImportMovieSearchResult from './ImportMovieSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingMovieSelector(), + (isExistingMovie) => { + return { + isExistingMovie + }; + } + ); +} + +export default connect(createMapStateToProps)(ImportMovieSearchResult); diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.css b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.css new file mode 100644 index 000000000..1a7f4836e --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.css @@ -0,0 +1,70 @@ +.tether { + z-index: 2000; +} + +.button { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: flex; + align-items: center; + padding: 6px 16px; + width: 100%; + height: 35px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; +} + +.loading { + display: inline-block; +} + +.warningIcon { + margin-right: 8px; +} + +.existing { + margin-left: 5px; +} + +.dropdownArrowContainer { + position: absolute; + right: 16px; +} + +.contentContainer { + margin-top: 4px; + padding: 0 8px; + width: 400px; +} + +.content { + padding: 4px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} + +.searchContainer { + display: flex; +} + +.searchIconContainer { + width: 58px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 33px; +} + +.searchInput { + composes: input from 'Components/Form/TextInput.css'; + + border-radius: 0; +} diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js new file mode 100644 index 000000000..c74e4126c --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from 'Components/Form/FormInputButton'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import ImportMovieSearchResultConnector from './ImportMovieSearchResultConnector'; +import ImportMovieTitle from './ImportMovieTitle'; +import styles from './ImportMovieSelectMovie.css'; + +const tetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ], + attachment: 'top center', + targetAttachment: 'bottom center' +}; + +class ImportMovieSelectMovie extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._movieLookupTimeout = null; + + this.state = { + term: props.id, + isOpen: false + }; + } + + // + // Control + + _setButtonRef = (ref) => { + this._buttonRef = ref; + } + + _setContentRef = (ref) => { + this._contentRef = ref; + } + + _addListener() { + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const button = ReactDOM.findDOMNode(this._buttonRef); + const content = ReactDOM.findDOMNode(this._contentRef); + + if (!button) { + return; + } + + if (!button.contains(event.target) && content && !content.contains(event.target) && this.state.isOpen) { + this.setState({ isOpen: false }); + this._removeListener(); + } + } + + onPress = () => { + if (this.state.isOpen) { + this._removeListener(); + } else { + this._addListener(); + } + + this.setState({ isOpen: !this.state.isOpen }); + } + + onSearchInputChange = ({ value }) => { + if (this._movieLookupTimeout) { + clearTimeout(this._movieLookupTimeout); + } + + this.setState({ term: value }, () => { + this._movieLookupTimeout = setTimeout(() => { + this.props.onSearchInputChange(value); + }, 200); + }); + } + + onRefreshPress = () => { + this.props.onSearchInputChange(this.state.term); + } + + onMovieSelect = (tmdbId) => { + this.setState({ isOpen: false }); + + this.props.onMovieSelect(tmdbId); + } + + // + // Render + + render() { + const { + selectedMovie, + isExistingMovie, + isFetching, + isPopulated, + error, + items, + queued, + isLookingUpMovie + } = this.props; + + const errorMessage = error && + error.responseJSON && + error.responseJSON.message; + + return ( + + + { + isLookingUpMovie && queued && !isPopulated && + + } + + { + isPopulated && selectedMovie && isExistingMovie && + + } + + { + isPopulated && selectedMovie && + + } + + { + isPopulated && !selectedMovie && +
+ + + No match found! +
+ } + + { + !isFetching && !!error && +
+ + + Search failed, please try again later. +
+ } + +
+ +
+ + + { + this.state.isOpen && +
+
+
+
+ +
+ + + + + + +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+
+ } +
+ ); + } +} + +ImportMovieSelectMovie.propTypes = { + id: PropTypes.string.isRequired, + selectedMovie: PropTypes.object, + isExistingMovie: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + queued: PropTypes.bool.isRequired, + isLookingUpMovie: PropTypes.bool.isRequired, + onSearchInputChange: PropTypes.func.isRequired, + onMovieSelect: PropTypes.func.isRequired +}; + +ImportMovieSelectMovie.defaultProps = { + isFetching: true, + isPopulated: false, + items: [], + queued: true +}; + +export default ImportMovieSelectMovie; diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovieConnector.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovieConnector.js new file mode 100644 index 000000000..31c94f294 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovieConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupMovie, setImportMovieValue } from 'Store/Actions/importMovieActions'; +import createImportMovieItemSelector from 'Store/Selectors/createImportMovieItemSelector'; +import ImportMovieSelectMovie from './ImportMovieSelectMovie'; + +function createMapStateToProps() { + return createSelector( + (state) => state.importMovie.isLookingUpMovie, + createImportMovieItemSelector(), + (isLookingUpMovie, item) => { + return { + isLookingUpMovie, + ...item + }; + } + ); +} + +const mapDispatchToProps = { + queueLookupMovie, + setImportMovieValue +}; + +class ImportMovieSelectMovieConnector extends Component { + + // + // Listeners + + onSearchInputChange = (term) => { + this.props.queueLookupMovie({ + name: this.props.id, + term, + topOfQueue: true + }); + } + + onMovieSelect = (tmdbId) => { + const { + id, + items + } = this.props; + + this.props.setImportMovieValue({ + id, + selectedMovie: _.find(items, { tmdbId }) + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportMovieSelectMovieConnector.propTypes = { + id: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + selectedMovie: PropTypes.object, + isSelected: PropTypes.bool, + queueLookupMovie: PropTypes.func.isRequired, + setImportMovieValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieSelectMovieConnector); diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.css b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.css new file mode 100644 index 000000000..f6ae0f4e6 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.css @@ -0,0 +1,17 @@ +.titleContainer { + display: flex; + align-items: center; +} + +.title { + margin-right: 5px; +} + +.year { + margin-left: 5px; + color: $disabledColor; +} + +.existing { + margin-left: 5px; +} diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.js new file mode 100644 index 000000000..fc5ba5b75 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieTitle.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import styles from './ImportMovieTitle.css'; + +function ImportMovieTitle(props) { + const { + title, + year, + studio, + isExistingMovie + } = props; + + return ( +
+
+ {title} + + { + !title.contains(year) && + ({year}) + } +
+ + { + !!studio && + + } + + { + isExistingMovie && + + } +
+ ); +} + +ImportMovieTitle.propTypes = { + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + studio: PropTypes.string, + isExistingMovie: PropTypes.bool.isRequired +}; + +export default ImportMovieTitle; diff --git a/frontend/src/AddMovie/ImportMovie/ImportMovies.js b/frontend/src/AddMovie/ImportMovie/ImportMovies.js new file mode 100644 index 000000000..b8d1a923f --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/ImportMovies.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import ImportMovieSelectFolderConnector from 'AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector'; +import ImportMovieConnector from 'AddMovie/ImportMovie/Import/ImportMovieConnector'; + +class ImportMovies extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default ImportMovies; diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.css b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.css new file mode 100644 index 000000000..d9c5ccb01 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.css @@ -0,0 +1,18 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + display: block; +} + +.freeSpace, +.unmappedFolders { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.js new file mode 100644 index 000000000..7730e3b43 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRow.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ImportMovieRootFolderRow.css'; + +function ImportMovieRootFolderRow(props) { + const { + id, + path, + freeSpace, + unmappedFolders, + onDeletePress + } = props; + + const unmappedFoldersCount = unmappedFolders.length || '-'; + + return ( + + + + {path} + + + + + {formatBytes(freeSpace) || '-'} + + + + {unmappedFoldersCount} + + + + + + + ); +} + +ImportMovieRootFolderRow.propTypes = { + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + freeSpace: PropTypes.number.isRequired, + unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onDeletePress: PropTypes.func.isRequired +}; + +ImportMovieRootFolderRow.defaultProps = { + freeSpace: 0, + unmappedFolders: [] +}; + +export default ImportMovieRootFolderRow; diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRowConnector.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRowConnector.js new file mode 100644 index 000000000..e25637472 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieRootFolderRowConnector.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import ImportMovieRootFolderRow from './ImportMovieRootFolderRow'; + +function createMapStateToProps() { + return createSelector( + () => { + return { + }; + } + ); +} + +const mapDispatchToProps = { + deleteRootFolder +}; + +class ImportMovieRootFolderRowConnector extends Component { + + // + // Listeners + + onDeletePress = () => { + this.props.deleteRootFolder({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportMovieRootFolderRowConnector.propTypes = { + id: PropTypes.number.isRequired, + deleteRootFolder: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieRootFolderRowConnector); diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.css b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.css new file mode 100644 index 000000000..030da96fb --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.css @@ -0,0 +1,32 @@ +.header { + margin-bottom: 40px; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.tips { + font-size: 20px; +} + +.tip { + font-size: $defaultFontSize; +} + +.code { + font-size: 12px; + font-family: $monoSpaceFontFamily; +} + +.recentFolders { + margin-top: 40px; +} + +.startImport { + margin-top: 40px; + text-align: center; +} + +.importButtonIcon { + margin-right: 8px; +} diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.js new file mode 100644 index 000000000..32211ed19 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolder.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ImportMovieRootFolderRowConnector from './ImportMovieRootFolderRowConnector'; +import styles from './ImportMovieSelectFolder.css'; + +const rootFolderColumns = [ + { + name: 'path', + label: 'Path', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'unmappedFolders', + label: 'Unmapped Folders', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +class ImportMovieSelectFolder extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNewRootFolderModalOpen: false + }; + } + + // + // Lifecycle + + onAddNewRootFolderPress = () => { + this.setState({ isAddNewRootFolderModalOpen: true }); + } + + onNewRootFolderSelect = ({ value }) => { + this.props.onNewRootFolderSelect(value); + } + + onAddRootFolderModalClose = () => { + this.setState({ isAddNewRootFolderModalOpen: false }); + } + + // + // Render + + render() { + const { + isWindows, + isFetching, + isPopulated, + error, + items + } = this.props; + + return ( + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load root folders
+ } + + { + !error && isPopulated && +
+
+ Import movies you already have +
+ +
+ Some tips to ensure the import goes smoothly: +
    +
  • + Make sure your files include the quality in the name. eg. episode.s02e15.bluray.mkv +
  • +
  • + Point Radarr to the folder containing all of your movies not a specific one. eg. "{isWindows ? 'C:\\movies' : '/movies'}" and not "{isWindows ? 'C:\\movies\\the matrix' : '/movies/the matrix'}" +
  • +
+
+ + { + items.length > 0 ? +
+
+ + + { + items.map((rootFolder) => { + return ( + + ); + }) + } + +
+
+ + +
: + +
+ +
+ } + + +
+ } +
+
+ ); + } +} + +ImportMovieSelectFolder.propTypes = { + isWindows: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onNewRootFolderSelect: PropTypes.func.isRequired, + onDeleteRootFolderPress: PropTypes.func.isRequired +}; + +export default ImportMovieSelectFolder; diff --git a/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector.js b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector.js new file mode 100644 index 000000000..a3d5b54d6 --- /dev/null +++ b/frontend/src/AddMovie/ImportMovie/SelectFolder/ImportMovieSelectFolderConnector.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import ImportMovieSelectFolder from './ImportMovieSelectFolder'; + +function createMapStateToProps() { + return createSelector( + (state) => state.rootFolders, + createSystemStatusSelector(), + (rootFolders, systemStatus) => { + return { + ...rootFolders, + isWindows: systemStatus.isWindows + }; + } + ); +} + +const mapDispatchToProps = { + fetchRootFolders, + addRootFolder, + deleteRootFolder, + push +}; + +class ImportMovieSelectFolderConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentDidUpdate(prevProps) { + const { + items, + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id); + + if (newRootFolders.length === 1) { + this.props.push(`${window.Radarr.urlBase}/add/import/${newRootFolders[0].id}`); + } + } + } + + // + // Listeners + + onNewRootFolderSelect = (path) => { + this.props.addRootFolder({ path }); + } + + onDeleteRootFolderPress = (id) => { + this.props.deleteRootFolder({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportMovieSelectFolderConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchRootFolders: PropTypes.func.isRequired, + addRootFolder: PropTypes.func.isRequired, + deleteRootFolder: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportMovieSelectFolderConnector); diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js new file mode 100644 index 000000000..d87c62827 --- /dev/null +++ b/frontend/src/App/App.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'react-router-redux'; +import PageConnector from 'Components/Page/PageConnector'; +import AppRoutes from './AppRoutes'; + +function App({ store, history }) { + return ( + + + + + + + + + + ); +} + +App.propTypes = { + store: PropTypes.object.isRequired, + history: PropTypes.object.isRequired +}; + +export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js new file mode 100644 index 000000000..dd6bfbae1 --- /dev/null +++ b/frontend/src/App/AppRoutes.js @@ -0,0 +1,232 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import MovieIndexConnector from 'Movie/Index/MovieIndexConnector'; +import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector'; +import ImportMovies from 'AddMovie/ImportMovie/ImportMovies'; +import SeriesEditorConnector from 'Movie/Editor/SeriesEditorConnector'; +import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector'; +import CalendarPageConnector from 'Calendar/CalendarPageConnector'; +import HistoryConnector from 'Activity/History/HistoryConnector'; +import QueueConnector from 'Activity/Queue/QueueConnector'; +import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector'; +import Settings from 'Settings/Settings'; +import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector'; +import Profiles from 'Settings/Profiles/Profiles'; +import Quality from 'Settings/Quality/Quality'; +import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import NetImportSettingsConnector from 'Settings/NetImport/NetImportSettingsConnector'; +import NotificationSettings from 'Settings/Notifications/NotificationSettings'; +import MetadataSettings from 'Settings/Metadata/MetadataSettings'; +import TagSettings from 'Settings/Tags/TagSettings'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import UISettingsConnector from 'Settings/UI/UISettingsConnector'; +import Status from 'System/Status/Status'; +import Tasks from 'System/Tasks/Tasks'; +import BackupsConnector from 'System/Backup/BackupsConnector'; +import UpdatesConnector from 'System/Updates/UpdatesConnector'; +import LogsTableConnector from 'System/Events/LogsTableConnector'; +import Logs from 'System/Logs/Logs'; + +function AppRoutes(props) { + const { + app + } = props; + + return ( + + {/* + Movies + */} + + + + { + window.Radarr.urlBase && + { + return ( + + ); + }} + /> + } + + + + + + + + + + {/* + Calendar + */} + + + + {/* + Activity + */} + + + + + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + ); +} + +AppRoutes.propTypes = { + app: PropTypes.func.isRequired +}; + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js new file mode 100644 index 000000000..abc7f8832 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; + +function AppUpdatedModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +AppUpdatedModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js new file mode 100644 index 000000000..a21afbc5a --- /dev/null +++ b/frontend/src/App/AppUpdatedModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import AppUpdatedModal from './AppUpdatedModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css new file mode 100644 index 000000000..37b89c9be --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.css @@ -0,0 +1,15 @@ +.version { + margin: 0 3px; + font-weight: bold; +} + +.maintenance { + margin-top: 20px; +} + +.changes { + margin-top: 20px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; + font-size: 18px; +} diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js new file mode 100644 index 000000000..d22eb98b5 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import styles from './AppUpdatedModalContent.css'; + +function AppUpdatedModalContent(props) { + const { + version, + isPopulated, + error, + items, + onSeeChangesPress, + onModalClose + } = props; + + const update = items[0]; + + return ( + + + Radarr Updated + + + +
+ Version {version} of Radarr has been installed, in order to get the latest changes you'll need to reload Radarr. +
+ + { + isPopulated && !error && !!update && +
+ { + !update.changes && +
Maintenance release
+ } + + { + !!update.changes && +
+
+ What's new? +
+ + + + +
+ } +
+ } + + { + !isPopulated && !error && + + } +
+ + + + + + +
+ ); +} + +AppUpdatedModalContent.propTypes = { + version: PropTypes.string.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeeChangesPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js new file mode 100644 index 000000000..325a1aa95 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContentConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.version, + (state) => state.system.updates, + (version, updates) => { + const { + isPopulated, + error, + items + } = updates; + + return { + version, + isPopulated, + error, + items + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchUpdates() { + dispatch(fetchUpdates()); + }, + + onSeeChangesPress() { + window.location = `${window.Radarr.urlBase}/system/updates`; + } + }; +} + +class AppUpdatedModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchUpdates(); + } + + componentDidUpdate(prevProps) { + if (prevProps.version !== this.props.version) { + this.props.dispatchFetchUpdates(); + } + } + + // + // Render + + render() { + const { + dispatchFetchUpdates, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AppUpdatedModalContentConnector.propTypes = { + version: PropTypes.string.isRequired, + dispatchFetchUpdates: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector); diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.js new file mode 100644 index 000000000..de98ac8fb --- /dev/null +++ b/frontend/src/App/ColorImpairedContext.js @@ -0,0 +1,6 @@ +import React from 'react'; + +const ColorImpairedContext = React.createContext(false); +export const ColorImpairedConsumer = ColorImpairedContext.Consumer; + +export default ColorImpairedContext; diff --git a/frontend/src/App/ConnectionLostModal.css b/frontend/src/App/ConnectionLostModal.css new file mode 100644 index 000000000..f0a9d220f --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.css @@ -0,0 +1,3 @@ +.automatic { + margin-top: 20px; +} diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js new file mode 100644 index 000000000..063b3f47f --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ConnectionLostModal.css'; + +function ConnectionLostModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + Connnection Lost + + + +
+ Radarr has lost it's connection to the backend and will need to be reloaded to restore functionality. +
+ +
+ Radarr will try to connect automatically, or you can click reload below. +
+
+ + + +
+
+ ); +} + +ConnectionLostModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js new file mode 100644 index 000000000..8ab8e3cd0 --- /dev/null +++ b/frontend/src/App/ConnectionLostModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import ConnectionLostModal from './ConnectionLostModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/Calendar/Agenda/Agenda.css b/frontend/src/Calendar/Agenda/Agenda.css new file mode 100644 index 000000000..0304d9db5 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.css @@ -0,0 +1,3 @@ +.agenda { + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js new file mode 100644 index 000000000..89472301d --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.js @@ -0,0 +1,38 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import AgendaEventConnector from './AgendaEventConnector'; +import styles from './Agenda.css'; + +function Agenda(props) { + const { + items + } = props; + + return ( +
+ { + items.map((item, index) => { + const momentDate = moment(item.airDateUtc); + const showDate = index === 0 || + !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); + + return ( + + ); + }) + } +
+ ); +} + +Agenda.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js new file mode 100644 index 000000000..b6f238873 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Agenda from './Agenda'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (calendar) => { + return calendar; + } + ); +} + +export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css new file mode 100644 index 000000000..bf0df3f2c --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -0,0 +1,117 @@ +.event { + display: flex; + overflow-x: hidden; + padding: 5px; + border-bottom: 1px solid $borderColor; + font-size: 14px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.eventWrapper { + display: flex; + flex: 1 0 1px; + overflow-x: hidden; + padding-left: 6px; + border-left-width: 4px; + border-left-style: solid; +} + +.date { + flex: 0 0 250px; + font-weight: bold; +} + +.time { + flex: 0 0 120px; + margin-right: 10px; + border: none !important; +} + +.seriesTitle, +.episodeTitle { + @add-mixin truncate; + + flex: 0 1 300px; + margin-right: 10px; +} + +.episodeTitle { + flex: 1 1 1px; +} + +.seasonEpisodeNumber { + flex: 0 0 100px; +} + +.episodeSeparator { + display: none; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .event { + flex-direction: column; + } + + .eventWrapper { + display: block; + flex: 0 0 auto; + } + + .date { + margin-left: 10px; + } + + .date, + .time, + .seriesTitle { + flex: 0 0 100%; + } + + .seasonEpisodeNumber { + flex: 0 0 auto; + } + + .episodeSeparator { + display: inline-block; + margin: 0 5px; + } +} diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js new file mode 100644 index 000000000..22b33532e --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.js @@ -0,0 +1,141 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import styles from './AgendaEvent.css'; + +class AgendaEvent extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + movieFile, + title, + titleSlug, + inCinemas, + monitored, + hasFile, + grabbed, + queueItem, + showDate, + showCutoffUnmetIcon, + longDateFormat, + colorImpairedMode + } = this.props; + + const startTime = moment(inCinemas); + const downloading = !!(queueItem || grabbed); + const isMonitored = monitored; + const statusStyle = getStatusStyle(hasFile, downloading, startTime, isMonitored); + + return ( +
+ +
+ { + showDate && + startTime.format(longDateFormat) + } +
+ +
+
+ +
+ + { + !!queueItem && + + + + } + + { + !queueItem && grabbed && + + } + + { + showCutoffUnmetIcon && + !!movieFile && + movieFile.qualityCutoffNotMet && + + } +
+ +
+ ); + } +} + +AgendaEvent.propTypes = { + id: PropTypes.number.isRequired, + episodeFile: PropTypes.object, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + inCinemas: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + showDate: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js new file mode 100644 index 000000000..ec6cae394 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import AgendaEvent from './AgendaEvent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createMovieSelector(), + createMovieFileSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (calendarOptions, series, episodeFile, queueItem, uiSettings) => { + return { + series, + episodeFile, + queueItem, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.css b/frontend/src/Calendar/Calendar.css new file mode 100644 index 000000000..37e6ff618 --- /dev/null +++ b/frontend/src/Calendar/Calendar.css @@ -0,0 +1,8 @@ +.calendar { + flex-grow: 1; + width: 100%; +} + +.calendarContent { + width: 100%; +} diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js new file mode 100644 index 000000000..6ceb1f3bb --- /dev/null +++ b/frontend/src/Calendar/Calendar.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import * as calendarViews from './calendarViews'; +import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; +import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; +import CalendarDaysConnector from './Day/CalendarDaysConnector'; +import AgendaConnector from './Agenda/AgendaConnector'; +import styles from './Calendar.css'; + +class Calendar extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + view + } = this.props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load the calendar
+ } + + { + !error && isPopulated && view === calendarViews.AGENDA && +
+ + +
+ } + + { + !error && isPopulated && view !== calendarViews.AGENDA && +
+ + + +
+ } +
+ ); + } +} + +Calendar.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + view: PropTypes.string.isRequired +}; + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js new file mode 100644 index 000000000..ef0f40b86 --- /dev/null +++ b/frontend/src/Calendar/CalendarConnector.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import * as calendarActions from 'Store/Actions/calendarActions'; +import { fetchMovieFiles, clearMovieFiles } from 'Store/Actions/movieFileActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as commandNames from 'Commands/commandNames'; +import Calendar from './Calendar'; + +const UPDATE_DELAY = 3600000; // 1 hour + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (state) => state.settings.ui.item.firstDayOfWeek, + createCommandExecutingSelector(commandNames.REFRESH_MOVIE), + (calendar, firstDayOfWeek, isRefreshingMovie) => { + return { + ...calendar, + isRefreshingMovie, + firstDayOfWeek + }; + } + ); +} + +const mapDispatchToProps = { + ...calendarActions, + fetchMovieFiles, + clearMovieFiles, + fetchQueueDetails, + clearQueueDetails +}; + +class CalendarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.updateTimeoutId = null; + } + + componentDidMount() { + registerPagePopulator(this.repopulate); + this.props.gotoCalendarToday(); + this.scheduleUpdate(); + } + + componentDidUpdate(prevProps) { + const { + items, + time, + view, + isRefreshingMovie, + firstDayOfWeek + } = this.props; + + if (hasDifferentItems(prevProps.items, items)) { + const episodeIds = selectUniqueIds(items, 'id'); + const episodeFileIds = selectUniqueIds(items, 'episodeFileId'); + + if (items.length) { + this.props.fetchQueueDetails({ episodeIds }); + } + + if (episodeFileIds.length) { + this.props.fetchMovieFiles({ episodeFileIds }); + } + } + + if (prevProps.time !== time) { + this.scheduleUpdate(); + } + + if (prevProps.firstDayOfWeek !== firstDayOfWeek) { + this.props.fetchCalendar({ time, view }); + } + + if (prevProps.isRefreshingMovie && !isRefreshingMovie) { + this.props.fetchCalendar({ time, view }); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearCalendar(); + this.props.clearQueueDetails(); + this.props.clearMovieFiles(); + this.clearUpdateTimeout(); + } + + // + // Control + + repopulate = () => { + const { + time, + view + } = this.props; + + this.props.fetchQueueDetails({ time, view }); + this.props.fetchCalendar({ time, view }); + } + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + + this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + updateCalendar = () => { + this.props.gotoCalendarToday(); + this.scheduleUpdate(); + } + + // + // Listeners + + onCalendarViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarConnector.propTypes = { + time: PropTypes.string, + view: PropTypes.string.isRequired, + firstDayOfWeek: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingMovie: PropTypes.bool.isRequired, + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired, + clearCalendar: PropTypes.func.isRequired, + fetchCalendar: PropTypes.func.isRequired, + fetchMovieFiles: PropTypes.func.isRequired, + clearMovieFiles: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarPage.css b/frontend/src/Calendar/CalendarPage.css new file mode 100644 index 000000000..776f3100f --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.css @@ -0,0 +1,14 @@ +.calendarPageBody { + composes: contentBody from 'Components/Page/PageContentBody.css'; + + display: flex; +} + +.calendarInnerPageBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; +} diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js new file mode 100644 index 000000000..7cb12da2a --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.js @@ -0,0 +1,152 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import Measure from 'Components/Measure'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import NoMovie from 'Movie/NoMovie'; +import CalendarLinkModal from './iCal/CalendarLinkModal'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import LegendConnector from './Legend/LegendConnector'; +import CalendarConnector from './CalendarConnector'; +import styles from './CalendarPage.css'; + +const MINIMUM_DAY_WIDTH = 120; + +class CalendarPage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isCalendarLinkModalOpen: false, + isOptionsModalOpen: false, + width: 0 + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ width }); + const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))); + + this.props.onDaysCountChange(days); + } + + onGetCalendarLinkPress = () => { + this.setState({ isCalendarLinkModalOpen: true }); + } + + onGetCalendarLinkModalClose = () => { + this.setState({ isCalendarLinkModalOpen: false }); + } + + onOptionsPress = () => { + this.setState({ isOptionsModalOpen: true }); + } + + onOptionsModalClose = () => { + this.setState({ isOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + hasSeries, + onFilterSelect + } = this.props; + + const { + isCalendarLinkModalOpen, + isOptionsModalOpen + } = this.state; + + const isMeasured = this.state.width > 0; + let PageComponent = 'div'; + + if (isMeasured) { + PageComponent = hasSeries ? CalendarConnector : NoMovie; + } + + return ( + + + + + + + + + + + + + + + + + + + { + hasSeries && + + } + + + + + + + ); + } +} + +CalendarPage.propTypes = { + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + hasSeries: PropTypes.bool.isRequired, + onDaysCountChange: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js new file mode 100644 index 000000000..276d7a5e2 --- /dev/null +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -0,0 +1,36 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; +import createMovieCountSelector from 'Store/Selectors/createMovieCountSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarPage from './CalendarPage'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createMovieCountSelector(), + createUISettingsSelector(), + (calendar, seriesCount, uiSettings) => { + return { + selectedFilterKey: calendar.selectedFilterKey, + filters: calendar.filters, + colorImpairedMode: uiSettings.enableColorImpairedMode, + hasSeries: !!seriesCount + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDaysCountChange(dayCount) { + dispatch(setCalendarDaysCount({ dayCount })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setCalendarFilter({ selectedFilterKey })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage); diff --git a/frontend/src/Calendar/Day/CalendarDay.css b/frontend/src/Calendar/Day/CalendarDay.css new file mode 100644 index 000000000..1c7694f0b --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.css @@ -0,0 +1,25 @@ +.day { + flex: 1 0 14.28%; + overflow: hidden; + min-height: 70px; + border-bottom: 1px solid $borderColor; + border-left: 1px solid $borderColor; +} + +.isSingleDay { + width: 100%; +} + +.dayOfMonth { + padding-right: 5px; + border-bottom: 1px solid $borderColor; + text-align: right; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} + +.isDifferentMonth { + color: $disabledColor; +} diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js new file mode 100644 index 000000000..faa45b28b --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.js @@ -0,0 +1,74 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector'; +import styles from './CalendarDay.css'; + +function CalendarDay(props) { + const { + date, + time, + isTodaysDate, + events, + view, + onEventModalOpenToggle + } = props; + + return ( +
+ { + view === calendarViews.MONTH && +
+ {moment(date).date()} +
+ } +
+ { + events.map((event) => { + if (event.isGroup) { + return ( + + ); + } + + return ( + + ); + }) + } +
+
+ ); +} + +CalendarDay.propTypes = { + date: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + events: PropTypes.arrayOf(PropTypes.object).isRequired, + view: PropTypes.string.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarDay; diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js new file mode 100644 index 000000000..8fd6cc5a1 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDayConnector.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import CalendarDay from './CalendarDay'; + +function sort(items) { + return _.sortBy(items, (item) => { + if (item.isGroup) { + return moment(item.events[0].airDateUtc).unix(); + } + + return moment(item.airDateUtc).unix(); + }); +} + +function createCalendarEventsConnector() { + return createSelector( + (state, { date }) => date, + (state) => state.calendar.items, + (state) => state.calendar.options.collapseMultipleEpisodes, + (date, items, collapseMultipleEpisodes) => { + const filtered = _.filter(items, (item) => { + return moment(date).isSame(moment(item.airDateUtc), 'day'); + }); + + if (!collapseMultipleEpisodes) { + return sort(filtered); + } + + const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`); + const grouped = []; + + Object.keys(groupedObject).forEach((key) => { + const events = groupedObject[key]; + + if (events.length === 1) { + grouped.push(events[0]); + } else { + grouped.push({ + isGroup: true, + seriesId: events[0].seriesId, + seasonNumber: events[0].seasonNumber, + episodeIds: events.map((event) => event.id), + events: _.sortBy(events, (item) => moment(item.airDateUtc).unix()) + }); + } + }); + + const sorted = sort(grouped); + + return sorted; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createCalendarEventsConnector(), + (calendar, events) => { + return { + time: calendar.time, + view: calendar.view, + events + }; + } + ); +} + +class CalendarDayConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarDayConnector.propTypes = { + date: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css new file mode 100644 index 000000000..22005e3e6 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.css @@ -0,0 +1,14 @@ +.days { + display: flex; + border-right: 1px solid $borderColor; +} + +.day, +.week, +.forecast { + flex-wrap: nowrap; +} + +.month { + flex-wrap: wrap; +} diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js new file mode 100644 index 000000000..0a1a36172 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.js @@ -0,0 +1,164 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import isToday from 'Utilities/Date/isToday'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarDayConnector from './CalendarDayConnector'; +import styles from './CalendarDays.css'; + +class CalendarDays extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStart = null; + + this.state = { + todaysDate: moment().startOf('day').toISOString(), + isEventModalOpen: false + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view === calendarViews.MONTH) { + this.scheduleUpdate(); + } + + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + window.addEventListener('touchmove', this.onTouchMove); + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + window.removeEventListener('touchmove', this.onTouchMove); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + this.setState({ todaysDate: todaysDate.toISOString() }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Listeners + + onEventModalOpenToggle = (isEventModalOpen) => { + this.setState({ isEventModalOpen }); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStart = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if ( + touchStart < 50 || + this.props.isSidebarVisible || + this.state.isEventModalOpen + ) { + return; + } + + this._touchStart = touchStart; + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStart) { + return; + } + + if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { + this.props.onNavigatePrevious(); + } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { + this.props.onNavigateNext(); + } + + this._touchStart = null; + } + + onTouchCancel = (event) => { + this._touchStart = null; + } + + onTouchMove = (event) => { + if (!this._touchStart) { + return; + } + } + + // + // Render + + render() { + const { + dates, + view + } = this.props; + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +CalendarDays.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string).isRequired, + view: PropTypes.string.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onNavigatePrevious: PropTypes.func.isRequired, + onNavigateNext: PropTypes.func.isRequired +}; + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js new file mode 100644 index 000000000..3dea906a7 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarDays from './CalendarDays'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (state) => state.app.isSidebarVisible, + (calendar, isSidebarVisible) => { + return { + dates: calendar.dates, + view: calendar.view, + isSidebarVisible + }; + } + ); +} + +const mapDispatchToProps = { + onNavigatePrevious: gotoCalendarPreviousRange, + onNavigateNext: gotoCalendarNextRange +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css new file mode 100644 index 000000000..8c3552e55 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.css @@ -0,0 +1,13 @@ +.dayOfWeek { + flex: 1 0 14.28%; + background-color: #e4eaec; + text-align: center; +} + +.isSingleDay { + width: 100%; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js new file mode 100644 index 000000000..d97671522 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.js @@ -0,0 +1,56 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DayOfWeek.css'; + +class DayOfWeek extends Component { + + // + // Render + + render() { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates + } = this.props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates); + } + + return ( +
+ {formatedDate} +
+ ); + } +} + +DayOfWeek.propTypes = { + date: PropTypes.string.isRequired, + view: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + shortDateFormat: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired +}; + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css b/frontend/src/Calendar/Day/DaysOfWeek.css new file mode 100644 index 000000000..518664633 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.css @@ -0,0 +1,4 @@ +.daysOfWeek { + display: flex; + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js new file mode 100644 index 000000000..a67777f7c --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DayOfWeek from './DayOfWeek'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DaysOfWeek.css'; + +class DaysOfWeek extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + todaysDate: moment().startOf('day').toISOString() + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { + this.scheduleUpdate(); + } + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = todaysDate.clone().add(1, 'day').diff(moment()); + + this.setState({ + todaysDate: todaysDate.toISOString() + }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Render + + render() { + const { + dates, + view, + ...otherProps + } = this.props; + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +DaysOfWeek.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string), + view: PropTypes.string.isRequired +}; + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js new file mode 100644 index 000000000..7f5cdef19 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeekConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DaysOfWeek from './DaysOfWeek'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createUISettingsSelector(), + (calendar, UiSettings) => { + return { + dates: calendar.dates.slice(0, 7), + view: calendar.view, + calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, + shortDateFormat: UiSettings.shortDateFormat, + showRelativeDates: UiSettings.showRelativeDates + }; + } + ); +} + +export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css new file mode 100644 index 000000000..3352d5127 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -0,0 +1,78 @@ +.event { + overflow-x: hidden; + margin: 4px 2px; + padding: 5px; + border-bottom: 1px solid $borderColor; + border-left: 4px solid $borderColor; + font-size: 12px; +} + +.info, +.episodeInfo { + display: flex; +} + +.seriesTitle, +.episodeTitle { + @add-mixin truncate; + + flex: 1 0 1px; + margin-right: 10px; +} + +.seriesTitle { + color: #3a3f51; + font-size: 14px; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + border-left-color: $successColor !important; +} + +.downloading { + border-left-color: $purple !important; +} + +.unmonitored { + border-left-color: $gray !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.onAir { + border-left-color: $warningColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.missing { + border-left-color: $dangerColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.unaired { + border-left-color: $primaryColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js new file mode 100644 index 000000000..fbc21b8cf --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.js @@ -0,0 +1,135 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import styles from './CalendarEvent.css'; + +class CalendarEvent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }, () => { + this.props.onEventModalOpenToggle(true); + }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }, () => { + this.props.onEventModalOpenToggle(false); + }); + } + + // + // Render + + render() { + const { + movieFile, + inCinemas, + title, + titleSlug, + monitored, + hasFile, + grabbed, + queueItem, + showCutoffUnmetIcon, + colorImpairedMode + } = this.props; + + const startTime = moment(inCinemas); + const isDownloading = !!(queueItem || grabbed); + const isMonitored = monitored; + const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, isMonitored); + + return ( +
+ +
+
+ +
+ + { + !!queueItem && + + + + } + + { + !queueItem && grabbed && + + } + + { + showCutoffUnmetIcon && + !!movieFile && + movieFile.qualityCutoffNotMet && + + } +
+ + +
+ ); + } +} + +CalendarEvent.propTypes = { + id: PropTypes.number.isRequired, + movieFile: PropTypes.object, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + inCinemas: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js new file mode 100644 index 000000000..7da98888e --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarEvent from './CalendarEvent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createMovieSelector(), + createMovieFileSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (calendarOptions, series, episodeFile, queueItem, uiSettings) => { + return { + series, + episodeFile, + queueItem, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css new file mode 100644 index 000000000..e16498f25 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.css @@ -0,0 +1,82 @@ +.eventGroup { + overflow-x: hidden; + margin: 4px 2px; + padding: 5px; + border-bottom: 1px solid $borderColor; + border-left: 4px solid $borderColor; + font-size: 12px; +} + +.info, +.airingInfo { + display: flex; +} + +.seriesTitle { + @add-mixin truncate; + + flex: 1 0 1px; + margin-right: 10px; + color: #3a3f51; + font-size: 14px; +} + +.airTime { + flex: 1 0 1px; +} + +.episodeInfo { + margin-left: 10px; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.expandContainerInline { + display: flex; + justify-content: flex-end; + flex: 1 0 20px; +} + +.expandContainer, +.collapseContainer { + display: flex; + justify-content: center; +} + +.collapseContainer { + margin-bottom: 5px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js new file mode 100644 index 000000000..186085c52 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.js @@ -0,0 +1,200 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import styles from './CalendarEventGroup.css'; + +function getEventsInfo(events) { + let files = 0; + let queued = 0; + let monitored = 0; + let absoluteEpisodeNumbers = 0; + + events.forEach((event) => { + if (event.episodeFileId) { + files++; + } + + if (event.queued) { + queued++; + } + + if (event.monitored) { + monitored++; + } + + if (event.absoluteEpisodeNumber) { + absoluteEpisodeNumbers++; + } + }); + + return { + allDownloaded: files === events.length, + anyQueued: queued > 0, + anyMonitored: monitored > 0, + allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length + }; +} + +class CalendarEventGroup extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isExpanded: false + }; + } + + // + // Listeners + + onExpandPress = () => { + this.setState({ isExpanded: !this.state.isExpanded }); + } + + // + // Render + + render() { + const { + series, + events, + isDownloading, + showEpisodeInformation, + showFinaleIcon, + colorImpairedMode, + onEventModalOpenToggle + } = this.props; + + const { isExpanded } = this.state; + const { + allDownloaded, + anyQueued, + anyMonitored + } = getEventsInfo(events); + const anyDownloading = isDownloading || anyQueued; + const firstEpisode = events[0]; + const lastEpisode = events[events.length -1]; + const airDateUtc = firstEpisode.airDateUtc; + const startTime = moment(airDateUtc); + const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); + const seasonNumber = firstEpisode.seasonNumber; + const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored); + + if (isExpanded) { + return ( +
+ { + events.map((event) => { + if (event.isGroup) { + return null; + } + + return ( + + ); + }) + } + + + + +
+ ); + } + + return ( +
+
+
+ {series.title} +
+ + { + anyDownloading && + + } + + { + firstEpisode.episodeNumber === 1 && seasonNumber > 0 && + + } + + { + showFinaleIcon && + lastEpisode.episodeNumber !== 1 && + seasonNumber > 0 && + lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount && + + } +
+ + { + showEpisodeInformation && + + + + } +
+ ); + } +} + +CalendarEventGroup.propTypes = { + series: PropTypes.object.isRequired, + events: PropTypes.arrayOf(PropTypes.object).isRequired, + isDownloading: PropTypes.bool.isRequired, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js new file mode 100644 index 000000000..e13e5b998 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarEventGroup from './CalendarEventGroup'; + +function createIsDownloadingSelector() { + return createSelector( + (state, { episodeIds }) => episodeIds, + (state) => state.queue.details, + (episodeIds, details) => { + return details.items.some((item) => { + return episodeIds.includes(item.episode.id); + }); + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createMovieSelector(), + createIsDownloadingSelector(), + createUISettingsSelector(), + (calendarOptions, series, isDownloading, uiSettings) => { + return { + series, + isDownloading, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarEventGroup); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js new file mode 100644 index 000000000..81d81465c --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import colors from 'Styles/Variables/colors'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import QueueDetails from 'Activity/Queue/QueueDetails'; + +function CalendarEventQueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status, + errorMessage + } = props; + + const progress = (100 - sizeleft / size * 100); + + return ( + + +
+ } + /> + ); +} + +CalendarEventQueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string +}; + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.css b/frontend/src/Calendar/Header/CalendarHeader.css new file mode 100644 index 000000000..1127bb3c3 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.css @@ -0,0 +1,53 @@ +.header { + display: flex; +} + +.navigationButtons { + flex: 1 1 33%; + text-align: left; +} + +.todayButton { + composes: button from 'Components/Link/Button.css'; + + margin-left: 5px; +} + +.titleDesktop, +.titleMobile { + text-align: center; + font-size: 18px; +} + +.titleMobile { + margin-bottom: 5px; +} + +.viewButtonsContainer { + display: flex; + justify-content: flex-end; + flex: 1 1 33%; +} + +.viewMenu { + composes: menu from 'Components/Menu/Menu.css'; + + line-height: 31px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-right: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .navigationButtons { + flex: 1 0 50%; + } + + .viewButtonsContainer { + flex: 0 0 100px; + } +} diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js new file mode 100644 index 000000000..6736e4b8c --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.js @@ -0,0 +1,224 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarHeaderViewButton from './CalendarHeaderViewButton'; +import styles from './CalendarHeader.css'; + +function getTitle(time, start, end, view, longDateFormat) { + const timeMoment = moment(time); + const startMoment = moment(start); + const endMoment = moment(end); + + if (view === 'day') { + return timeMoment.format(longDateFormat); + } else if (view === 'month') { + return timeMoment.format('MMMM YYYY'); + } else if (view === 'agenda') { + return 'Agenda'; + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; +} + +// TODO Convert to a stateful Component so we can track view internally when changed + +class CalendarHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + view: props.view + }; + } + + componentDidUpdate(prevProps) { + const view = this.props.view; + + if (prevProps.view !== view) { + this.setState({ view }); + } + } + + // + // Listeners + + onViewChange = (view) => { + this.setState({ view }, () => { + this.props.onViewChange(view); + }); + } + + // + // Render + + render() { + const { + isFetching, + time, + start, + end, + longDateFormat, + isSmallScreen, + onTodayPress, + onPreviousPress, + onNextPress + } = this.props; + + const view = this.state.view; + + const title = getTitle(time, start, end, view, longDateFormat); + + return ( +
+ { + isSmallScreen && +
+ {title} +
+ } + +
+
+ + + + + +
+ + { + !isSmallScreen && +
+ {title} +
+ } + +
+ { + isFetching && + + } + + { + isSmallScreen ? + + + + + + + + Forecast + + + + Day + + + + Agenda + + + : + +
+ + + +
+ } +
+
+
+ ); + } +} + +CalendarHeader.propTypes = { + isFetching: PropTypes.bool.isRequired, + time: PropTypes.string.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + view: PropTypes.oneOf(calendarViews.all).isRequired, + isSmallScreen: PropTypes.bool.isRequired, + longDateFormat: PropTypes.string.isRequired, + onViewChange: PropTypes.func.isRequired, + onTodayPress: PropTypes.func.isRequired, + onPreviousPress: PropTypes.func.isRequired, + onNextPress: PropTypes.func.isRequired +}; + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js new file mode 100644 index 000000000..c96cf2869 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js @@ -0,0 +1,84 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { setCalendarView, gotoCalendarToday, gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarHeader from './CalendarHeader'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createDimensionsSelector(), + createUISettingsSelector(), + (calendar, dimensions, uiSettings) => { + const result = _.pick(calendar, [ + 'isFetching', + 'view', + 'time', + 'start', + 'end' + ]); + + result.isSmallScreen = dimensions.isSmallScreen; + result.longDateFormat = uiSettings.longDateFormat; + + return result; + } + ); +} + +const mapDispatchToProps = { + setCalendarView, + gotoCalendarToday, + gotoCalendarPreviousRange, + gotoCalendarNextRange +}; + +class CalendarHeaderConnector extends Component { + + // + // Listeners + + onViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarHeaderConnector.propTypes = { + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js new file mode 100644 index 000000000..8dd5ae9f0 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import Button from 'Components/Link/Button'; +import * as calendarViews from 'Calendar/calendarViews'; +// import styles from './CalendarHeaderViewButton.css'; + +class CalendarHeaderViewButton extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.view); + } + + // + // Render + + render() { + const { + view, + selectedView, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +CalendarHeaderViewButton.propTypes = { + view: PropTypes.oneOf(calendarViews.all).isRequired, + selectedView: PropTypes.oneOf(calendarViews.all).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.css b/frontend/src/Calendar/Legend/Legend.css new file mode 100644 index 000000000..296cbd9d5 --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.css @@ -0,0 +1,6 @@ +.legend { + display: flex; + flex-wrap: wrap; + margin-top: 10px; + padding: 3px 0; +} diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js new file mode 100644 index 000000000..9bc6303f3 --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import LegendItem from './LegendItem'; +import LegendIconItem from './LegendIconItem'; +import styles from './Legend.css'; + +function Legend(props) { + const { + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + colorImpairedMode + } = props; + + const iconsToShow = []; + if (showFinaleIcon) { + iconsToShow.push( + + ); + } + + if (showSpecialIcon) { + iconsToShow.push( + + ); + } + + if (showCutoffUnmetIcon) { + iconsToShow.push( + + ); + } + + return ( +
+
+ + + +
+ +
+ + + +
+ +
+ + + {iconsToShow[0]} +
+ + { + iconsToShow.length > 1 && +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ } +
+ ); +} + +Legend.propTypes = { + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js new file mode 100644 index 000000000..30bbc4adb --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import Legend from './Legend'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createUISettingsSelector(), + (calendarOptions, uiSettings) => { + return { + ...calendarOptions, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css new file mode 100644 index 000000000..01db0ba5a --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.css @@ -0,0 +1,10 @@ +.legendIconItem { + margin: 3px 0; + margin-right: 6px; + width: 150px; + cursor: default; +} + +.icon { + margin-right: 5px; +} diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js new file mode 100644 index 000000000..13e106784 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './LegendIconItem.css'; + +function LegendIconItem(props) { + const { + name, + icon, + kind, + tooltip + } = props; + + return ( +
+ + + {name} +
+ ); +} + +LegendIconItem.propTypes = { + name: PropTypes.string.isRequired, + icon: PropTypes.object.isRequired, + kind: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired +}; + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.css b/frontend/src/Calendar/Legend/LegendItem.css new file mode 100644 index 000000000..d146e9d68 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.css @@ -0,0 +1,41 @@ +.legendItem { + margin: 3px 0; + margin-right: 6px; + padding-left: 5px; + width: 150px; + border-left-width: 4px; + border-left-style: solid; + cursor: default; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} + +.unaired { + composes: unaired from 'Calendar/Events/CalendarEvent.css'; +} diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js new file mode 100644 index 000000000..961f48b86 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import titleCase from 'Utilities/String/titleCase'; +import styles from './LegendItem.css'; + +function LegendItem(props) { + const { + name, + status, + tooltip, + colorImpairedMode + } = props; + + return ( +
+ {name ? name : titleCase(status)} +
+ ); +} + +LegendItem.propTypes = { + name: PropTypes.string, + status: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js new file mode 100644 index 000000000..b68c83f30 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; + +function CalendarOptionsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js new file mode 100644 index 000000000..9ff7b3f82 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js @@ -0,0 +1,258 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings'; + +class CalendarOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = props; + + this.state = { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }; + } + + componentDidUpdate(prevProps) { + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.props; + + if ( + prevProps.firstDayOfWeek !== firstDayOfWeek || + prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || + prevProps.timeFormat !== timeFormat || + prevProps.enableColorImpairedMode !== enableColorImpairedMode + ) { + this.setState({ + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }); + } + } + + // + // Listeners + + onOptionInputChange = ({ name, value }) => { + const { + dispatchSetCalendarOption + } = this.props; + + dispatchSetCalendarOption({ [name]: value }); + } + + onGlobalInputChange = ({ name, value }) => { + const { + dispatchSaveUISettings + } = this.props; + + const setting = { [name]: value }; + + this.setState(setting, () => { + dispatchSaveUISettings(setting); + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + collapseMultipleEpisodes, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + onModalClose + } = this.props; + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.state; + + return ( + + + Calendar Options + + + +
+
+ + Collapse Multiple Episodes + + + + + + Show Episode Information + + + + + + Icon for Finales + + + + + + Icon for Specials + + + + + + Icon for Cutoff Unmet + + + +
+
+ +
+
+ + First Day of Week + + + + + + Week Column Header + + + + + + Time Format + + + + Enable Color-Impaired Mode + + + + +
+
+
+ + + + +
+ ); + } +} + +CalendarOptionsModalContent.propTypes = { + collapseMultipleEpisodes: PropTypes.bool.isRequired, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + firstDayOfWeek: PropTypes.number.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + enableColorImpairedMode: PropTypes.bool.isRequired, + dispatchSetCalendarOption: PropTypes.func.isRequired, + dispatchSaveUISettings: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js new file mode 100644 index 000000000..eb979f74e --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setCalendarOption } from 'Store/Actions/calendarActions'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; +import { saveUISettings } from 'Store/Actions/settingsActions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + (state) => state.settings.ui.item, + (options, uiSettings) => { + return { + ...options, + ...uiSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetCalendarOption: setCalendarOption, + dispatchSaveUISettings: saveUISettings +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js new file mode 100644 index 000000000..627e1bb61 --- /dev/null +++ b/frontend/src/Calendar/calendarViews.js @@ -0,0 +1,4 @@ +export const MONTH = 'month'; +export const AGENDA = 'agenda'; + +export const all = [MONTH, AGENDA]; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js new file mode 100644 index 000000000..871cccd5b --- /dev/null +++ b/frontend/src/Calendar/getStatusStyle.js @@ -0,0 +1,26 @@ +/* eslint max-params: 0 */ +import moment from 'moment'; + +function getStatusStyle(hasFile, downloading, startTime, isMonitored) { + const currentTime = moment(); + + if (hasFile) { + return 'downloaded'; + } + + if (downloading) { + return 'downloading'; + } + + if (!isMonitored) { + return 'unmonitored'; + } + + if (startTime.isBefore(currentTime) && !hasFile) { + return 'missing'; + } + + return 'unaired'; +} + +export default getStatusStyle; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js new file mode 100644 index 000000000..8cc487c16 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; + +function CalendarLinkModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarLinkModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js new file mode 100644 index 000000000..600a4ba61 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js @@ -0,0 +1,221 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function getUrls(state) { + const { + unmonitored, + premieresOnly, + asAllDay, + tags + } = state; + + let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/calendar/Radarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (premieresOnly) { + icalUrl += 'premieresOnly=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${window.Radarr.apiKey}`; + + const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`; + const iCalWebCalUrl = `webcal://${icalUrl}`; + + return { + iCalHttpUrl, + iCalWebCalUrl + }; +} + +class CalendarLinkModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const defaultState = { + unmonitored: false, + premieresOnly: false, + asAllDay: false, + tags: [] + }; + + const urls = getUrls(defaultState); + + this.state = { + ...defaultState, + ...urls + }; + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + const state = { + ...this.state, + [name]: value + }; + + const urls = getUrls(state); + + this.setState({ + [name]: value, + ...urls + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + unmonitored, + premieresOnly, + asAllDay, + tags, + iCalHttpUrl, + iCalWebCalUrl + } = this.state; + + return ( + + + Radarr Calendar Feed + + + +
+ + Include Unmonitored + + + + + + Season Premieres Only + + + + + + Show as All-Day Events + + + + + + Tags + + + + + + iCal Feed + + , + + + + + ]} + onChange={this.onInputChange} + onFocus={this.onLinkFocus} + /> + +
+
+ + + + +
+ ); + } +} + +CalendarLinkModalContent.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js new file mode 100644 index 000000000..e10c5c3f9 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import CalendarLinkModalContent from './CalendarLinkModalContent'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarLinkModalContent); diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js new file mode 100644 index 000000000..fc73bafdb --- /dev/null +++ b/frontend/src/Commands/commandNames.js @@ -0,0 +1,18 @@ +export const APPLICATION_UPDATE = 'ApplicationUpdate'; +export const BACKUP = 'Backup'; +export const CHECK_FOR_FINISHED_DOWNLOAD = 'CheckForFinishedDownload'; +export const CLEAR_BLACKLIST = 'ClearBlacklist'; +export const CLEAR_LOGS = 'ClearLog'; +export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch'; +export const DELETE_LOG_FILES = 'DeleteLogFiles'; +export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; +export const DOWNLOADED_EPSIODES_SCAN = 'DownloadedEpisodesScan'; +export const INTERACTIVE_IMPORT = 'ManualImport'; +export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch'; +export const MOVE_MOVIE = 'MoveMovie'; +export const REFRESH_MOVIE = 'RefreshMovie'; +export const RENAME_FILES = 'RenameFiles'; +export const RENAME_SERIES = 'RenameSeries'; +export const RESET_API_KEY = 'ResetApiKey'; +export const RSS_SYNC = 'RssSync'; +export const MOVIE_SEARCH = 'MoviesSearch'; diff --git a/frontend/src/Components/Alert.css b/frontend/src/Components/Alert.css new file mode 100644 index 000000000..312fbb4f2 --- /dev/null +++ b/frontend/src/Components/Alert.css @@ -0,0 +1,31 @@ +.alert { + display: block; + margin: 5px; + padding: 15px; + border: 1px solid transparent; + border-radius: 4px; +} + +.danger { + border-color: $alertDangerBorderColor; + background-color: $alertDangerBackgroundColor; + color: $alertDangerColor; +} + +.info { + border-color: $alertInfoBorderColor; + background-color: $alertInfoBackgroundColor; + color: $alertInfoColor; +} + +.success { + border-color: $alertSuccessBorderColor; + background-color: $alertSuccessBackgroundColor; + color: $alertSuccessColor; +} + +.warning { + border-color: $alertWarningBorderColor; + background-color: $alertWarningBackgroundColor; + color: $alertWarningColor; +} diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js new file mode 100644 index 000000000..dc19a418c --- /dev/null +++ b/frontend/src/Components/Alert.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds } from 'Helpers/Props'; +import styles from './Alert.css'; + +function Alert({ className, kind, children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +Alert.propTypes = { + className: PropTypes.string.isRequired, + kind: PropTypes.oneOf(kinds.all).isRequired, + children: PropTypes.node.isRequired +}; + +Alert.defaultProps = { + className: styles.alert, + kind: kinds.INFO +}; + +export default Alert; diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css new file mode 100644 index 000000000..b54bbcdf4 --- /dev/null +++ b/frontend/src/Components/Card.css @@ -0,0 +1,19 @@ +.card { + position: relative; + margin: 10px; + padding: 10px; + border-radius: 3px; + background-color: $white; + box-shadow: 0 0 10px 1px $cardShadowColor; + color: $defaultColor; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + position: relative; +} diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js new file mode 100644 index 000000000..c5a4d164c --- /dev/null +++ b/frontend/src/Components/Card.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './Card.css'; + +class Card extends Component { + + // + // Render + + render() { + const { + className, + overlayClassName, + overlayContent, + children, + onPress + } = this.props; + + if (overlayContent) { + return ( +
+ + +
+ {children} +
+
+ ); + } + + return ( + + {children} + + ); + } +} + +Card.propTypes = { + className: PropTypes.string.isRequired, + overlayClassName: PropTypes.string.isRequired, + overlayContent: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + onPress: PropTypes.func.isRequired +}; + +Card.defaultProps = { + className: styles.card, + overlayClassName: styles.overlay, + overlayContent: false +}; + +export default Card; diff --git a/frontend/src/Components/CircularProgressBar.css b/frontend/src/Components/CircularProgressBar.css new file mode 100644 index 000000000..32b349404 --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.css @@ -0,0 +1,21 @@ +.circularProgressBarContainer { + position: relative; + display: inline-block; + vertical-align: top; + text-align: center; +} + +.circularProgressBar { + position: absolute; + top: 0; + left: 0; + transform: rotate(-90deg); + transform-origin: center center; +} + +.circularProgressBarText { + position: absolute; + width: 100%; + height: 100%; + font-weight: bold; +} diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js new file mode 100644 index 000000000..b526eef3b --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import colors from 'Styles/Variables/colors'; +import styles from './CircularProgressBar.css'; + +class CircularProgressBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + progress: 0 + }; + } + + componentDidMount() { + this._progressStep(); + } + + componentDidUpdate(prevProps) { + const progress = this.props.progress; + + if (prevProps.progress !== progress) { + this._cancelProgressStep(); + this._progressStep(); + } + } + + componentWillUnmount() { + this._cancelProgressStep(); + } + + // + // Control + + _progressStep() { + this.requestAnimationFrame = window.requestAnimationFrame(() => { + this.setState({ + progress: this.state.progress + 1 + }, () => { + if (this.state.progress < this.props.progress) { + this._progressStep(); + } + }); + }); + } + + _cancelProgressStep() { + if (this.requestAnimationFrame) { + window.cancelAnimationFrame(this.requestAnimationFrame); + } + } + + // + // Render + + render() { + const { + className, + containerClassName, + size, + strokeWidth, + strokeColor, + showProgressText + } = this.props; + + const progress = this.state.progress; + + const center = size / 2; + const radius = center - strokeWidth; + const circumference = Math.PI * (radius * 2); + const sizeInPixels = `${size}px`; + const strokeDashoffset = ((100 - progress) / 100) * circumference; + const progressText = `${Math.round(progress)}%`; + + return ( +
+ + + + + { + showProgressText && +
+ {progressText} +
+ } +
+ ); + } +} + +CircularProgressBar.propTypes = { + className: PropTypes.string, + containerClassName: PropTypes.string, + size: PropTypes.number, + progress: PropTypes.number.isRequired, + strokeWidth: PropTypes.number, + strokeColor: PropTypes.string, + showProgressText: PropTypes.bool +}; + +CircularProgressBar.defaultProps = { + className: styles.circularProgressBar, + containerClassName: styles.circularProgressBarContainer, + size: 60, + strokeWidth: 5, + strokeColor: colors.radarrYellow, + showProgressText: false +}; + +export default CircularProgressBar; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css b/frontend/src/Components/DescriptionList/DescriptionList.css new file mode 100644 index 000000000..230347f80 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.css @@ -0,0 +1,4 @@ +.descriptionList { + margin-top: 0; + margin-bottom: 0; +} diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js new file mode 100644 index 000000000..be2c87c55 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './DescriptionList.css'; + +class DescriptionList extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +DescriptionList.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node +}; + +DescriptionList.defaultProps = { + className: styles.descriptionList +}; + +export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js new file mode 100644 index 000000000..4ba70bf33 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DescriptionListItemTitle from './DescriptionListItemTitle'; +import DescriptionListItemDescription from './DescriptionListItemDescription'; + +class DescriptionListItem extends Component { + + // + // Render + + render() { + const { + titleClassName, + descriptionClassName, + title, + data + } = this.props; + + return ( + + + {title} + + + + {data} + + + ); + } +} + +DescriptionListItem.propTypes = { + titleClassName: PropTypes.string, + descriptionClassName: PropTypes.string, + title: PropTypes.string, + data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css new file mode 100644 index 000000000..b23415a76 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css @@ -0,0 +1,13 @@ +.description { + line-height: $lineHeight; +} + +.description { + margin-left: 0; +} + +@media (min-width: 768px) { + .description { + margin-left: 180px; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js new file mode 100644 index 000000000..4ef3c015e --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemDescription.css'; + +function DescriptionListItemDescription(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemDescription.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +DescriptionListItemDescription.defaultProps = { + className: styles.description +}; + +export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css new file mode 100644 index 000000000..e496e463d --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css @@ -0,0 +1,18 @@ +.title { + line-height: $lineHeight; +} + +.title { + font-weight: bold; +} + +@media (min-width: 768px) { + .title { + @add-mixin truncate; + + float: left; + clear: left; + width: 160px; + text-align: right; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js new file mode 100644 index 000000000..e1632c1cf --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemTitle.css'; + +function DescriptionListItemTitle(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemTitle.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.string +}; + +DescriptionListItemTitle.defaultProps = { + className: styles.title +}; + +export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DragPreviewLayer.css b/frontend/src/Components/DragPreviewLayer.css new file mode 100644 index 000000000..46f721fef --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.css @@ -0,0 +1,9 @@ +.dragLayer { + position: fixed; + top: 0; + left: 0; + z-index: 9999; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js new file mode 100644 index 000000000..a111df70e --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DragPreviewLayer.css'; + +function DragPreviewLayer({ children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +DragPreviewLayer.propTypes = { + children: PropTypes.node, + className: PropTypes.string +}; + +DragPreviewLayer.defaultProps = { + className: styles.dragLayer +}; + +export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js new file mode 100644 index 000000000..50fcf98b5 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import * as sentry from '@sentry/browser'; + +class ErrorBoundary extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + error: null, + info: null + }; + } + + componentDidCatch(error, info) { + this.setState({ + error, + info + }); + + sentry.captureException(error); + } + + // + // Render + + render() { + const { + children, + errorComponent: ErrorComponent, + ...otherProps + } = this.props; + + const { + error, + info + } = this.state; + + if (error) { + return ( + + ); + } + + return children; + } +} + +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + errorComponent: PropTypes.func.isRequired +}; + +export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css new file mode 100644 index 000000000..b6d1f917e --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.css @@ -0,0 +1,38 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.imageContainer { + display: flex; + justify-content: center; + flex: 0 0 auto; +} + +.image { + height: 350px; +} + +.details { + margin: 20px; + text-align: left; + white-space: pre-wrap; +} + +@media only screen and (max-width: $breakpointMedium) { + .image { + height: 250px; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .image { + height: 150px; + } +} diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js new file mode 100644 index 000000000..05cf8165a --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './ErrorBoundaryError.css'; + +function ErrorBoundaryError(props) { + const { + className, + messageClassName, + detailsClassName, + message, + error, + info + } = props; + + return ( +
+
+ {message} +
+ +
+ +
+ +
+ { + error && +
+ {error.toString()} +
+ } + +
+ {info.componentStack} +
+
+
+ ); +} + +ErrorBoundaryError.propTypes = { + className: PropTypes.string.isRequired, + messageClassName: PropTypes.string.isRequired, + detailsClassName: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + error: PropTypes.object.isRequired, + info: PropTypes.object.isRequired +}; + +ErrorBoundaryError.defaultProps = { + className: styles.container, + messageClassName: styles.message, + detailsClassName: styles.details, + message: 'There was an error loading this content' +}; + +export default ErrorBoundaryError; diff --git a/frontend/src/Components/FieldSet.css b/frontend/src/Components/FieldSet.css new file mode 100644 index 000000000..daf3bdf2e --- /dev/null +++ b/frontend/src/Components/FieldSet.css @@ -0,0 +1,19 @@ +.fieldSet { + margin: 0; + margin-bottom: 20px; + padding: 0; + min-width: 0; + border: 0; +} + +.legend { + display: block; + margin-bottom: 21px; + padding: 0; + width: 100%; + border: 0; + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + line-height: inherit; +} diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js new file mode 100644 index 000000000..76e68a934 --- /dev/null +++ b/frontend/src/Components/FieldSet.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './FieldSet.css'; + +class FieldSet extends Component { + + // + // Render + + render() { + const { + legend, + children + } = this.props; + + return ( +
+ + {legend} + + {children} +
+ ); + } + +} + +FieldSet.propTypes = { + legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + children: PropTypes.node +}; + +export default FieldSet; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css b/frontend/src/Components/FileBrowser/FileBrowserModal.css new file mode 100644 index 000000000..30b936800 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css @@ -0,0 +1,5 @@ +.modal { + composes: modal from 'Components/Modal/Modal.css'; + + height: 600px; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js new file mode 100644 index 000000000..6b58dbb8c --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FileBrowserModalContentConnector from './FileBrowserModalContentConnector'; +import styles from './FileBrowserModal.css'; + +class FileBrowserModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +FileBrowserModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css new file mode 100644 index 000000000..9ae11f0bd --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css @@ -0,0 +1,33 @@ +.modalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + flex-direction: column; +} + +.mappedDrivesWarning { + composes: alert from 'Components/Alert.css'; + + margin: 0; + margin-bottom: 20px; +} + +.faqLink { + color: $alertWarningColor; + font-weight: bold; +} + +.pathInput { + composes: pathInputWrapper from 'Components/Form/PathInput.css'; + + flex: 0 0 auto; +} + +.scroller { + margin-top: 20px; +} + +.loading { + display: inline-block; + margin-right: auto; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js new file mode 100644 index 000000000..cc8bc7529 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js @@ -0,0 +1,253 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Scroller from 'Components/Scroller/Scroller'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PathInput from 'Components/Form/PathInput'; +import FileBrowserRow from './FileBrowserRow'; +import styles from './FileBrowserModalContent.css'; + +const columns = [ + { + name: 'type', + label: 'Type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + } +]; + +class FileBrowserModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scrollerNode = null; + + this.state = { + isFileBrowserModalOpen: false, + currentPath: props.value + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + currentPath + } = this.props; + + if ( + currentPath !== this.state.currentPath && + currentPath !== prevState.currentPath + ) { + this.setState({ currentPath }); + this._scrollerNode.scrollTop = 0; + } + } + + // + // Control + + setScrollerRef = (ref) => { + if (ref) { + this._scrollerNode = ReactDOM.findDOMNode(ref); + } else { + this._scrollerNode = null; + } + } + + // + // Listeners + + onPathInputChange = ({ value }) => { + this.setState({ currentPath: value }); + } + + onRowPress = (path) => { + this.props.onFetchPaths(path); + } + + onOkPress = () => { + this.props.onChange({ + name: this.props.name, + value: this.state.currentPath + }); + + this.props.onClearPaths(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + parent, + directories, + files, + isWindowsService, + onModalClose, + ...otherProps + } = this.props; + + const emptyParent = parent === ''; + + return ( + + + File Browser + + + + { + isWindowsService && + + Mapped network drives are not available when running as a Windows Service, see the FAQ for more information. + + } + + + + + { + !!error && +
Error loading contents
+ } + + { + isPopulated && !error && + + + { + emptyParent && + + } + + { + !emptyParent && parent && + + } + + { + directories.map((directory) => { + return ( + + ); + }) + } + + { + files.map((file) => { + return ( + + ); + }) + } + +
+ } +
+
+ + + { + isFetching && + + } + + + + + +
+ ); + } +} + +FileBrowserModalContent.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + parent: PropTypes.string, + currentPath: PropTypes.string.isRequired, + directories: PropTypes.arrayOf(PropTypes.object).isRequired, + files: PropTypes.arrayOf(PropTypes.object).isRequired, + isWindowsService: PropTypes.bool.isRequired, + onFetchPaths: PropTypes.func.isRequired, + onClearPaths: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js new file mode 100644 index 000000000..fe577b896 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js @@ -0,0 +1,101 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchPaths, clearPaths } from 'Store/Actions/pathActions'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import FileBrowserModalContent from './FileBrowserModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.paths, + createSystemStatusSelector(), + (paths, systemStatus) => { + const { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files + } = paths; + + const filteredPaths = _.filter([...directories, ...files], ({ path }) => { + return path.toLowerCase().startsWith(currentPath.toLowerCase()); + }); + + return { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + paths: filteredPaths, + isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service' + }; + } + ); +} + +const mapDispatchToProps = { + fetchPaths, + clearPaths +}; + +class FileBrowserModalContentConnector extends Component { + + // Lifecycle + + componentDidMount() { + this.props.fetchPaths({ + path: this.props.value, + allowFoldersWithoutTrailingSlashes: true + }); + } + + // + // Listeners + + onFetchPaths = (path) => { + this.props.fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true + }); + } + + onClearPaths = () => { + // this.props.clearPaths(); + } + + onModalClose = () => { + this.props.clearPaths(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +FileBrowserModalContentConnector.propTypes = { + value: PropTypes.string, + fetchPaths: PropTypes.func.isRequired, + clearPaths: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector); diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css b/frontend/src/Components/FileBrowser/FileBrowserRow.css new file mode 100644 index 000000000..a9c34be6a --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css @@ -0,0 +1,5 @@ +.type { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 32px; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js new file mode 100644 index 000000000..42ac30405 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './FileBrowserRow.css'; + +function getIconName(type) { + switch (type) { + case 'computer': + return icons.COMPUTER; + case 'drive': + return icons.DRIVE; + case 'file': + return icons.FILE; + case 'parent': + return icons.PARENT; + default: + return icons.FOLDER; + } +} + +class FileBrowserRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.path); + } + + // + // Render + + render() { + const { + type, + name + } = this.props; + + return ( + + + + + + {name} + + ); + } + +} + +FileBrowserRow.propTypes = { + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default FileBrowserRow; diff --git a/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js new file mode 100644 index 000000000..eea574dd1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: true, name: 'true' }, + { id: false, name: 'false' } +]; + +function BoolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default BoolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css new file mode 100644 index 000000000..fd56a4917 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css @@ -0,0 +1,15 @@ +.container { + display: flex; +} + +.numberInput { + composes: input from 'Components/Form/TextInput.css'; + + margin-right: 3px; +} + +.selectInput { + composes: select from 'Components/Form/SelectInput.css'; + + margin-left: 3px; +} diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js new file mode 100644 index 000000000..f0c2d3626 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js @@ -0,0 +1,171 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import isString from 'Utilities/String/isString'; +import { IN_LAST, IN_NEXT } from 'Helpers/Props/filterTypes'; +import NumberInput from 'Components/Form/NumberInput'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import { NAME } from './FilterBuilderRowValue'; +import styles from './DateFilterBuilderRowValue.css'; + +const timeOptions = [ + { key: 'seconds', value: 'seconds' }, + { key: 'minutes', value: 'minutes' }, + { key: 'hours', value: 'hours' }, + { key: 'days', value: 'days' }, + { key: 'weeks', value: 'weeks' }, + { key: 'months', value: 'months' } +]; + +function isInFilter(filterType) { + return filterType === IN_LAST || filterType === IN_NEXT; +} + +class DateFilterBuilderRowValue extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + filterType, + filterValue, + onChange + } = this.props; + + if (isInFilter(filterType) && isString(filterValue)) { + onChange({ + name: NAME, + value: { + time: timeOptions[0].key, + value: null + } + }); + } + } + + componentDidUpdate(prevProps) { + const { + filterType, + filterValue, + onChange + } = this.props; + + if (prevProps.filterType === filterType) { + return; + } + + if (isInFilter(filterType) && isString(filterValue)) { + onChange({ + name: NAME, + value: { + time: timeOptions[0].key, + value: null + } + }); + + return; + } + + if (!isInFilter(filterType) && !isString(filterValue)) { + onChange({ + name: NAME, + value: '' + }); + } + } + + // + // Listeners + + onValueChange = ({ value }) => { + const { + filterValue, + onChange + } = this.props; + + let newValue = value; + + if (!isString(value)) { + newValue = { + time: filterValue.time, + value + }; + } + + onChange({ + name: NAME, + value: newValue + }); + } + + onTimeChange = ({ value }) => { + const { + filterValue, + onChange + } = this.props; + + onChange({ + name: NAME, + value: { + time: value, + value: filterValue.value + } + }); + } + + // + // Render + + render() { + const { + filterType, + filterValue + } = this.props; + + if ( + (isInFilter(filterType) && isString(filterValue)) || + (!isInFilter(filterType) && !isString(filterValue)) + ) { + return null; + } + + if (isInFilter(filterType)) { + return ( +
+ + + +
+ ); + } + + return ( + + ); + } +} + +DateFilterBuilderRowValue.propTypes = { + filterType: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + onChange: PropTypes.func.isRequired +}; + +export default DateFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css new file mode 100644 index 000000000..6cc8fab67 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css @@ -0,0 +1,16 @@ +.labelContainer { + margin-bottom: 20px; +} + +.label { + margin-bottom: 5px; + font-weight: bold; +} + +.labelInputContainer { + width: 300px; +} + +.rows { + margin-bottom: 100px; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js new file mode 100644 index 000000000..ed3bc2409 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -0,0 +1,226 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import FilterBuilderRow from './FilterBuilderRow'; +import styles from './FilterBuilderModalContent.css'; + +class FilterBuilderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const filters = [...props.filters]; + + // Push an empty filter if there aren't any filters. FilterBuilderRow + // will handle initializing the filter. + + if (!filters.length) { + filters.push({}); + } + + this.state = { + label: props.label, + filters, + labelErrors: [] + }; + } + + componentDidUpdate(prevProps) { + const { + id, + customFilters, + isSaving, + saveError, + dispatchSetFilter, + onModalClose + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + if (id) { + dispatchSetFilter({ selectedFilterKey: id }); + } else { + const last = customFilters[customFilters.length -1]; + dispatchSetFilter({ selectedFilterKey: last.id }); + } + + onModalClose(); + } + } + + // + // Listeners + + onLabelChange = ({ value }) => { + this.setState({ label: value }); + } + + onFilterChange = (index, filter) => { + const filters = [...this.state.filters]; + filters.splice(index, 1, filter); + + this.setState({ + filters + }); + } + + onAddFilterPress = () => { + const filters = [...this.state.filters]; + filters.push({}); + + this.setState({ + filters + }); + } + + onRemoveFilterPress = (index) => { + const filters = [...this.state.filters]; + filters.splice(index, 1); + + this.setState({ + filters + }); + } + + onSaveFilterPress = () => { + const { + id, + customFilterType, + onSaveCustomFilterPress + } = this.props; + + const { + label, + filters + } = this.state; + + if (!label) { + this.setState({ + labelErrors: [ + { + message: 'Label is required' + } + ] + }); + + return; + } + + onSaveCustomFilterPress({ + id, + type: customFilterType, + label, + filters + }); + } + + // + // Render + + render() { + const { + sectionItems, + filterBuilderProps, + isSaving, + saveError, + onModalClose + } = this.props; + + const { + label, + filters, + labelErrors + } = this.state; + + return ( + + + Custom Filter + + + +
+
+ Label +
+ +
+ +
+
+ +
Filters
+ +
+ { + filters.map((filter, index) => { + return ( + + ); + }) + } +
+
+ + + + + + Save + + +
+ ); + } +} + +FilterBuilderModalContent.propTypes = { + id: PropTypes.number, + label: PropTypes.string.isRequired, + customFilterType: PropTypes.string.isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, + onSaveCustomFilterPress: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterBuilderModalContent; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js new file mode 100644 index 000000000..c94db9925 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js @@ -0,0 +1,42 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { saveCustomFilter, deleteCustomFilter } from 'Store/Actions/customFilterActions'; +import FilterBuilderModalContent from './FilterBuilderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { customFilters }) => customFilters, + (state, { id }) => id, + (state) => state.customFilters.isSaving, + (state) => state.customFilters.saveError, + (customFilters, id, isSaving, saveError) => { + if (id) { + const customFilter = customFilters.find((c) => c.id === id); + + return { + id: customFilter.id, + label: customFilter.label, + filters: customFilter.filters, + customFilters, + isSaving, + saveError + }; + } + + return { + label: '', + filters: [], + customFilters, + isSaving, + saveError + }; + } + ); +} + +const mapDispatchToProps = { + onSaveCustomFilterPress: saveCustomFilter, + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css new file mode 100644 index 000000000..c5471b253 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css @@ -0,0 +1,32 @@ +.filterRow { + display: flex; + margin-bottom: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.inputContainer { + flex: 0 1 200px; + margin-right: 10px; +} + +.valueInputContainer { + flex: 0 1 300px; + margin-right: 10px; +} + +.actionsContainer { + display: flex; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterRow { + display: block; + } + + .inputContainer { + margin-bottom: 10px; + } +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js new file mode 100644 index 000000000..53f28a90e --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -0,0 +1,282 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import IconButton from 'Components/Link/IconButton'; +import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; +import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; +import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; +import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; +import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; +import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; +import styles from './FilterBuilderRow.css'; + +function getselectedFilterBuilderProp(filterBuilderProps, name) { + return filterBuilderProps.find((a) => { + return a.name === name; + }); +} + +function getFilterTypeOptions(filterBuilderProps, filterKey) { + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey); + + if (!selectedFilterBuilderProp) { + return []; + } + + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type]; +} + +function getDefaultFilterType(selectedFilterBuilderProp) { + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key; +} + +function getDefaultFilterValue(selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.type === filterBuilderTypes.DATE) { + return ''; + } + + return []; +} + +function getRowValueConnector(selectedFilterBuilderProp) { + if (!selectedFilterBuilderProp) { + return FilterBuilderRowValueConnector; + } + + const valueType = selectedFilterBuilderProp.valueType; + + switch (valueType) { + case filterBuilderValueTypes.BOOL: + return BoolFilterBuilderRowValue; + + case filterBuilderValueTypes.DATE: + return DateFilterBuilderRowValue; + + case filterBuilderValueTypes.INDEXER: + return IndexerFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.PROTOCOL: + return ProtocolFilterBuilderRowValue; + + case filterBuilderValueTypes.QUALITY: + return QualityFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.QUALITY_PROFILE: + return QualityProfileFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.SERIES_STATUS: + return SeriesStatusFilterBuilderRowValue; + + case filterBuilderValueTypes.TAG: + return TagFilterBuilderRowValueConnector; + + default: + return FilterBuilderRowValueConnector; + } +} + +class FilterBuilderRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + filterKey, + filterBuilderProps + } = props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + } + } + + componentDidMount() { + const { + index, + filterKey, + filterBuilderProps, + onFilterChange + } = this.props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + + return; + } + + const selectedFilterBuilderProp = filterBuilderProps[0]; + + const filter = { + key: selectedFilterBuilderProp.name, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type: getDefaultFilterType(selectedFilterBuilderProp) + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + // + // Listeners + + onFilterKeyChange = ({ value: key }) => { + const { + index, + filterBuilderProps, + onFilterChange + } = this.props; + + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key); + const type = getDefaultFilterType(selectedFilterBuilderProp); + + const filter = { + key, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + onFilterChange = ({ name, value }) => { + const { + index, + filterKey, + filterValue, + filterType, + onFilterChange + } = this.props; + + const filter = { + key: filterKey, + value: filterValue, + type: filterType + }; + + filter[name] = value; + + onFilterChange(index, filter); + } + + onAddPress = () => { + const { + index, + onAddPress + } = this.props; + + onAddPress(index); + } + + onRemovePress = () => { + const { + index, + onRemovePress + } = this.props; + + onRemovePress(index); + } + + // + // Render + + render() { + const { + filterKey, + filterType, + filterValue, + filterCount, + filterBuilderProps, + sectionItems + } = this.props; + + const selectedFilterBuilderProp = this.selectedFilterBuilderProp; + + const keyOptions = filterBuilderProps.map((availablePropFilter) => { + return { + key: availablePropFilter.name, + value: availablePropFilter.label + }; + }); + + const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); + + return ( +
+
+ { + filterKey && + + } +
+ +
+ { + filterType && + + } +
+ +
+ { + filterValue != null && !!selectedFilterBuilderProp && + + } +
+ +
+ + + +
+
+ ); + } +} + +FilterBuilderRow.propTypes = { + index: PropTypes.number.isRequired, + filterKey: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]), + filterType: PropTypes.string, + filterCount: PropTypes.number.isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + onFilterChange: PropTypes.func.isRequired, + onAddPress: PropTypes.func.isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default FilterBuilderRow; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js new file mode 100644 index 000000000..70c496620 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -0,0 +1,159 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import convertToBytes from 'Utilities/Number/convertToBytes'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; +import TagInput, { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValueTag from './FilterBuilderRowValueTag'; + +export const NAME = 'value'; + +function getTagDisplayValue(value, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + return formatBytes(value); + } + + return value; +} + +function getValue(input, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + const match = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + if (match && match.length > 1) { + const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + switch (unit.toLowerCase()) { + case 'k': + return convertToBytes(value, 1, true); + case 'm': + return convertToBytes(value, 2, true); + case 'g': + return convertToBytes(value, 3, true); + case 't': + return convertToBytes(value, 4, true); + case 'kb': + return convertToBytes(value, 1, true); + case 'mb': + return convertToBytes(value, 2, true); + case 'gb': + return convertToBytes(value, 3, true); + case 'tb': + return convertToBytes(value, 4, true); + case 'kib': + return convertToBytes(value, 1, true); + case 'mib': + return convertToBytes(value, 2, true); + case 'gib': + return convertToBytes(value, 3, true); + case 'tib': + return convertToBytes(value, 4, true); + default: + return parseInt(value); + } + } + } + + if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) { + return parseInt(input); + } + + return input; +} + +class FilterBuilderRowValue extends Component { + + // + // Listeners + + onTagAdd = (tag) => { + const { + filterValue, + selectedFilterBuilderProp, + onChange + } = this.props; + + let value = tag.id; + + if (value == null) { + value = getValue(tag.name, selectedFilterBuilderProp); + } + + onChange({ + name: NAME, + value: [...filterValue, value] + }); + } + + onTagDelete = ({ index }) => { + const { + filterValue, + onChange + } = this.props; + + const value = filterValue.filter((v, i) => i !== index); + + onChange({ + name: NAME, + value + }); + } + + // + // Render + + render() { + const { + filterValue, + selectedFilterBuilderProp, + tagList + } = this.props; + + const hasItems = !!tagList.length; + + const tags = filterValue.map((id) => { + if (hasItems) { + const tag = tagList.find((t) => t.id === id); + + return { + id, + name: tag && tag.name + }; + } + + return { + id, + name: getTagDisplayValue(id, selectedFilterBuilderProp) + }; + }); + + return ( + + ); + } +} + +FilterBuilderRowValue.propTypes = { + filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])).isRequired, + selectedFilterBuilderProp: PropTypes.object.isRequired, + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + onChange: PropTypes.func.isRequired +}; + +FilterBuilderRowValue.defaultProps = { + filterValue: [] +}; + +export default FilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js new file mode 100644 index 000000000..ac74240e4 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes } from 'Helpers/Props'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createTagListSelector() { + return createSelector( + (state, { sectionItems }) => sectionItems, + (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp, + (sectionItems, selectedFilterBuilderProp) => { + if ( + selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER || + selectedFilterBuilderProp.type === filterBuilderTypes.STRING + ) { + return []; + } + + let items = []; + + if (selectedFilterBuilderProp.optionsSelector) { + items = selectedFilterBuilderProp.optionsSelector(sectionItems); + } else { + items = sectionItems.reduce((acc, item) => { + const name = item[selectedFilterBuilderProp.name]; + + if (name) { + acc.push({ + id: name, + name + }); + } + + return acc; + }, []).sort(sortByName); + } + + return _.uniqBy(items, 'id'); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createTagListSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css new file mode 100644 index 000000000..1c4c5acf1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css @@ -0,0 +1,19 @@ +.tag { + &.isLastTag { + .or { + display: none; + } + } +} + +.label { + composes: label from 'Components/Label.css'; + + border-style: none; + font-size: 13px; +} + +.or { + margin: 0 3px; + color: $themeDarkColor; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js new file mode 100644 index 000000000..573e05759 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import TagInputTag from 'Components/Form/TagInputTag'; +import styles from './FilterBuilderRowValueTag.css'; + +function FilterBuilderRowValueTag(props) { + return ( + + + + { + !props.isLastTag && + + or + + } + + ); +} + +FilterBuilderRowValueTag.propTypes = { + isLastTag: PropTypes.bool.isRequired +}; + +export default FilterBuilderRowValueTag; diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..968b26d2c --- /dev/null +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (qualityProfiles) => { + const { + isFetching, + isPopulated, + error, + items + } = qualityProfiles; + + const tagList = items.map((item) => { + return { + id: item.id, + name: item.name + }; + }); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexers: fetchIndexers +}; + +class IndexerFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchIndexers(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +IndexerFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchIndexers: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js new file mode 100644 index 000000000..ae63ae0eb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'torrent', name: 'Torrent' }, + { id: 'usenet', name: 'Usenet' } +]; + +function ProtocolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default ProtocolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..0290bcdcb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + const tagList = getQualities(schema.items); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfileSchema: fetchQualityProfileSchema +}; + +class QualityFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchQualityProfileSchema(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +QualityFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..4a8b82283 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const tagList = qualityProfiles.items.map((qualityProfile) => { + const { + id, + name + } = qualityProfile; + + return { + id, + name + }; + }); + + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js new file mode 100644 index 000000000..50841a013 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'continuing', name: 'Continuing' }, + { id: 'ended', name: 'Ended' } +]; + +function SeriesStatusFilterBuilderRowValue(props) { + return ( + + ); +} + +export default SeriesStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..60e04c446 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList: tagList.map((tag) => { + const { + id, + label: name + } = tag; + + return { + id, + name + }; + }) + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css new file mode 100644 index 000000000..7acb69dc7 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css @@ -0,0 +1,17 @@ +.customFilter { + display: flex; + margin-bottom: 5px; + padding: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.label { + flex: 0 1 300px; +} + +.actions { + flex: 0 0 60px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js new file mode 100644 index 000000000..c9c326d78 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import styles from './CustomFilter.css'; + +class CustomFilter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDeleting: false + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) { + this.setState({ isDeleting: false }); + } + } + + componentWillUnmount() { + const { + id, + selectedFilterKey, + dispatchSetFilter + } = this.props; + + // Assume that delete and then unmounting means the delete was successful. + // Moving this check to a ancestor would be more accurate, but would have + // more boilerplate. + if (this.state.isDeleting && id === selectedFilterKey) { + dispatchSetFilter({ selectedFilterKey: 'all' }); + } + } + + // + // Listeners + + onEditPress = () => { + const { + id, + onEditPress + } = this.props; + + onEditPress(id); + } + + onRemovePress = () => { + const { + id, + dispatchDeleteCustomFilter + } = this.props; + + this.setState({ isDeleting: true }, () => { + dispatchDeleteCustomFilter({ id }); + }); + + } + + // + // Render + + render() { + const { + label + } = this.props; + + return ( +
+
+ {label} +
+ +
+ + + +
+
+ ); + } +} + +CustomFilter.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchSetFilter: PropTypes.func.isRequired, + onEditPress: PropTypes.func.isRequired, + dispatchDeleteCustomFilter: PropTypes.func.isRequired +}; + +export default CustomFilter; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css new file mode 100644 index 000000000..c391764dc --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css @@ -0,0 +1,3 @@ +.addButtonContainer { + margin-top: 15px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js new file mode 100644 index 000000000..1a7168fca --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CustomFilter from './CustomFilter'; +import styles from './CustomFiltersModalContent.css'; + +function CustomFiltersModalContent(props) { + const { + selectedFilterKey, + customFilters, + isDeleting, + deleteError, + dispatchDeleteCustomFilter, + dispatchSetFilter, + onAddCustomFilter, + onEditCustomFilter, + onModalClose + } = props; + + return ( + + + Custom Filters + + + + { + customFilters.map((customFilter, index) => { + return ( + + ); + }) + } + +
+ +
+
+ + + + +
+ ); +} + +CustomFiltersModalContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, + onAddCustomFilter: PropTypes.func.isRequired, + onEditCustomFilter: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CustomFiltersModalContent; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js new file mode 100644 index 000000000..32425d766 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteCustomFilter } from 'Store/Actions/customFilterActions'; +import CustomFiltersModalContent from './CustomFiltersModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.customFilters.isDeleting, + (state) => state.customFilters.deleteError, + (isDeleting, deleteError) => { + return { + isDeleting, + deleteError + }; + } + ); +} + +const mapDispatchToProps = { + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent); diff --git a/frontend/src/Components/Filter/FilterModal.js b/frontend/src/Components/Filter/FilterModal.js new file mode 100644 index 000000000..750d1ed48 --- /dev/null +++ b/frontend/src/Components/Filter/FilterModal.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector'; +import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector'; + +class FilterModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filterBuilder: !props.customFilters.length, + id: null + }; + } + + // + // Listeners + + onAddCustomFilter = () => { + this.setState({ + filterBuilder: true + }); + } + + onEditCustomFilter = (id) => { + this.setState({ + filterBuilder: true, + id + }); + } + + onModalClose = () => { + this.setState({ + filterBuilder: false, + id: null + }, () => { + this.props.onModalClose(); + }); + } + + // + // Render + + render() { + const { + isOpen, + ...otherProps + } = this.props; + + const { + filterBuilder, + id + } = this.state; + + return ( + + { + filterBuilder ? + : + + } + + ); + } +} + +FilterModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterModal; diff --git a/frontend/src/Components/Form/AutoCompleteInput.css b/frontend/src/Components/Form/AutoCompleteInput.css new file mode 100644 index 000000000..417a71437 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.css @@ -0,0 +1,58 @@ +.input { + composes: input from 'Components/Form/Input.css'; +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} + +.inputWrapper { + display: flex; +} + +.inputContainer { + position: relative; + flex-grow: 1; +} + +.container { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.inputContainerOpen { + .container { + position: absolute; + z-index: 1; + overflow-y: auto; + max-height: 200px; + width: 100%; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + } +} + +.list { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} + +.listItem { + padding: 0 16px; +} + +.match { + font-weight: bold; +} + +.highlighted { + background-color: $menuItemHoverBackgroundColor; +} diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js new file mode 100644 index 000000000..740726b36 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.js @@ -0,0 +1,162 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import classNames from 'classnames'; +import jdu from 'jdu'; +import styles from './AutoCompleteInput.css'; + +class AutoCompleteInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + suggestions: [] + }; + } + + // + // Control + + getSuggestionValue(item) { + return item; + } + + renderSuggestion(item) { + return item; + } + + // + // Listeners + + onInputChange = (event, { newValue }) => { + this.props.onChange({ + name: this.props.name, + value: newValue + }); + } + + onInputKeyDown = (event) => { + const { + name, + value, + onChange + } = this.props; + + const { suggestions } = this.state; + + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== this.props.value + ) { + event.preventDefault(); + + if (value) { + onChange({ + name, + value: suggestions[0] + }); + } + } + } + + onInputBlur = () => { + this.setState({ suggestions: [] }); + } + + onSuggestionsFetchRequested = ({ value }) => { + const { values } = this.props; + const lowerCaseValue = jdu.replace(value).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().contains(lowerCaseValue); + }); + + this.setState({ suggestions: filteredValues }); + } + + onSuggestionsClearRequested = () => { + this.setState({ suggestions: [] }); + } + + // + // Render + + render() { + const { + className, + inputClassName, + name, + value, + placeholder, + hasError, + hasWarning + } = this.props; + + const { suggestions } = this.state; + + const inputProps = { + className: classNames( + inputClassName, + hasError && styles.hasError, + hasWarning && styles.hasWarning, + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: this.onInputChange, + onKeyDown: this.onInputKeyDown, + onBlur: this.onInputBlur + }; + + const theme = { + container: styles.inputContainer, + containerOpen: styles.inputContainerOpen, + suggestionsContainer: styles.container, + suggestionsList: styles.list, + suggestion: styles.listItem, + suggestionHighlighted: styles.highlighted + }; + + return ( +
+ +
+ ); + } +} + +AutoCompleteInput.propTypes = { + className: PropTypes.string.isRequired, + inputClassName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string, + values: PropTypes.arrayOf(PropTypes.string).isRequired, + placeholder: PropTypes.string, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + onChange: PropTypes.func.isRequired +}; + +AutoCompleteInput.defaultProps = { + className: styles.inputWrapper, + inputClassName: styles.input, + value: '' +}; + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/CaptchaInput.css b/frontend/src/Components/Form/CaptchaInput.css new file mode 100644 index 000000000..e7cd1dc4e --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.css @@ -0,0 +1,23 @@ +.captchaInputWrapper { + display: flex; +} + +.input { + composes: input from 'Components/Form/Input.css'; +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} + +.hasButton { + composes: hasButton from 'Components/Form/Input.css'; +} + +.recaptchaWrapper { + margin-top: 10px; +} diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js new file mode 100644 index 000000000..e1a5df458 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from './FormInputButton'; +import TextInput from './TextInput'; +import styles from './CaptchaInput.css'; + +function CaptchaInput(props) { + const { + className, + name, + value, + hasError, + hasWarning, + refreshing, + siteKey, + secretToken, + onChange, + onRefreshPress, + onCaptchaChange + } = props; + + return ( +
+
+ + + + + +
+ + { + !!siteKey && !!secretToken && +
+ +
+ } +
+ ); +} + +CaptchaInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + refreshing: PropTypes.bool.isRequired, + siteKey: PropTypes.string, + secretToken: PropTypes.string, + onChange: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onCaptchaChange: PropTypes.func.isRequired +}; + +CaptchaInput.defaultProps = { + className: styles.input, + value: '' +}; + +export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js new file mode 100644 index 000000000..17b875c88 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInputConnector.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { refreshCaptcha, getCaptchaCookie, resetCaptcha } from 'Store/Actions/captchaActions'; +import CaptchaInput from './CaptchaInput'; + +function createMapStateToProps() { + return createSelector( + (state) => state.captcha, + (captcha) => { + return captcha; + } + ); +} + +const mapDispatchToProps = { + refreshCaptcha, + getCaptchaCookie, + resetCaptcha +}; + +class CaptchaInputConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + name, + token, + onChange + } = this.props; + + if (token && token !== prevProps.token) { + onChange({ name, value: token }); + } + } + + componentWillUnmount = () => { + this.props.resetCaptcha(); + } + + // + // Listeners + + onRefreshPress = () => { + const { + provider, + providerData + } = this.props; + + this.props.refreshCaptcha({ provider, providerData }); + } + + onCaptchaChange = (captchaResponse) => { + // If the captcha has expired `captchaResponse` will be null. + // In the event it's null don't try to get the captchaCookie. + // TODO: Should we clear the cookie? or reset the captcha? + + if (!captchaResponse) { + return; + } + + const { + provider, + providerData + } = this.props; + + this.props.getCaptchaCookie({ provider, providerData, captchaResponse }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CaptchaInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + token: PropTypes.string, + onChange: PropTypes.func.isRequired, + refreshCaptcha: PropTypes.func.isRequired, + getCaptchaCookie: PropTypes.func.isRequired, + resetCaptcha: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector); diff --git a/frontend/src/Components/Form/CheckInput.css b/frontend/src/Components/Form/CheckInput.css new file mode 100644 index 000000000..5c35e5d2f --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.css @@ -0,0 +1,105 @@ +.container { + position: relative; + display: flex; + flex: 1 1 65%; + user-select: none; +} + +.label { + display: flex; + margin-bottom: 0; + min-height: 21px; + font-weight: normal; + cursor: pointer; +} + +.checkbox { + position: absolute; + opacity: 0; + cursor: pointer; + pointer-events: none; + + &:global(.isDisabled) { + cursor: not-allowed; + } +} + +.input { + flex: 1 0 auto; + margin-top: 7px; + margin-right: 5px; + width: 20px; + height: 20px; + border: 1px solid #ccc; + border-radius: 2px; + background-color: $white; + color: $white; + text-align: center; + line-height: 20px; +} + +.checkbox:focus + .input { + outline: 0; + border-color: $inputFocusBorderColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor; +} + +.dangerIsChecked { + border-color: $dangerColor; + background-color: $dangerColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.primaryIsChecked { + border-color: $primaryColor; + background-color: $primaryColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.successIsChecked { + border-color: $successColor; + background-color: $successColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.warningIsChecked { + border-color: $warningColor; + background-color: $warningColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.isNotChecked { + &.isDisabled { + border-color: $disabledCheckInputColor; + background-color: $disabledCheckInputColor; + opacity: 0.7; + } +} + +.isIndeterminate { + border-color: $gray; + background-color: $gray; +} + +.helpText { + composes: helpText from 'Components/Form/FormInputHelpText.css'; + + margin-top: 8px; + margin-left: 5px; +} + +.isDisabled { + cursor: not-allowed; +} diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js new file mode 100644 index 000000000..134290111 --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.js @@ -0,0 +1,191 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputHelpText from './FormInputHelpText'; +import styles from './CheckInput.css'; + +class CheckInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._checkbox = null; + } + + componentDidMount() { + this.setIndeterminate(); + } + + componentDidUpdate() { + this.setIndeterminate(); + } + + // + // Control + + setIndeterminate() { + if (!this._checkbox) { + return; + } + + const { + value, + uncheckedValue, + checkedValue + } = this.props; + + this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue; + } + + toggleChecked = (checked, shiftKey) => { + const { + name, + value, + checkedValue, + uncheckedValue + } = this.props; + + const newValue = checked ? checkedValue : uncheckedValue; + + if (value !== newValue) { + this.props.onChange({ + name, + value: newValue, + shiftKey + }); + } + } + + // + // Listeners + + setRef = (ref) => { + this._checkbox = ref; + } + + onClick = (event) => { + if (this.props.isDisabled) { + return; + } + + const shiftKey = event.nativeEvent.shiftKey; + const checked = !this._checkbox.checked; + + event.preventDefault(); + this.toggleChecked(checked, shiftKey); + } + + onChange = (event) => { + const checked = event.target.checked; + const shiftKey = event.nativeEvent.shiftKey; + + this.toggleChecked(checked, shiftKey); + } + + // + // Render + + render() { + const { + className, + containerClassName, + name, + value, + checkedValue, + uncheckedValue, + helpText, + helpTextWarning, + isDisabled, + kind + } = this.props; + + const isChecked = value === checkedValue; + const isUnchecked = value === uncheckedValue; + const isIndeterminate = !isChecked && !isUnchecked; + const isCheckClass = `${kind}IsChecked`; + + return ( +
+ +
+ ); + } +} + +CheckInput.propTypes = { + className: PropTypes.string.isRequired, + containerClassName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + checkedValue: PropTypes.bool, + uncheckedValue: PropTypes.bool, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + helpText: PropTypes.string, + helpTextWarning: PropTypes.string, + isDisabled: PropTypes.bool, + kind: PropTypes.oneOf(kinds.all).isRequired, + onChange: PropTypes.func.isRequired +}; + +CheckInput.defaultProps = { + className: styles.input, + containerClassName: styles.container, + checkedValue: true, + uncheckedValue: false, + kind: kinds.PRIMARY +}; + +export default CheckInput; diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css new file mode 100644 index 000000000..0c518e98e --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.css @@ -0,0 +1,8 @@ +.deviceInputWrapper { + display: flex; +} + +.inputContainer { + composes: inputContainer from './TagInput.css'; + composes: hasButton from 'Components/Form/Input.css'; +} diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js new file mode 100644 index 000000000..a38648e1a --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from './FormInputButton'; +import TagInput, { tagShape } from './TagInput'; +import styles from './DeviceInput.css'; + +class DeviceInput extends Component { + + onTagAdd = (device) => { + const { + name, + value, + onChange + } = this.props; + + // New tags won't have an ID, only a name. + const deviceId = device.id || device.name; + + onChange({ + name, + value: [...value, deviceId] + }); + } + + onTagDelete = ({ index }) => { + const { + name, + value, + onChange + } = this.props; + + const newValue = value.slice(); + newValue.splice(index, 1); + + onChange({ + name, + value: newValue + }); + } + + // + // Render + + render() { + const { + className, + items, + selectedDevices, + hasError, + hasWarning, + isFetching, + onRefreshPress + } = this.props; + + return ( +
+ + + + + +
+ ); + } +} + +DeviceInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, + items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired +}; + +DeviceInput.defaultProps = { + className: styles.deviceInputWrapper, + inputClassName: styles.input +}; + +export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js new file mode 100644 index 000000000..d53372b35 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDevices, clearDevices } from 'Store/Actions/deviceActions'; +import DeviceInput from './DeviceInput'; + +function createMapStateToProps() { + return createSelector( + (state, { value }) => value, + (state) => state.devices, + (value, devices) => { + + return { + ...devices, + selectedDevices: value.map((valueDevice) => { + // Disable equality ESLint rule so we don't need to worry about + // a type mismatch between the value items and the device ID. + // eslint-disable-next-line eqeqeq + const device = devices.items.find((d) => d.id == valueDevice); + + if (device) { + return { + id: device.id, + name: `${device.name} (${device.id})` + }; + } + + return { + id: valueDevice, + name: `Unknown (${valueDevice})` + }; + }) + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDevices: fetchDevices, + dispatchClearDevices: clearDevices +}; + +class DeviceInputConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + this._populate(); + } + + componentWillUnmount = () => { + // this.props.dispatchClearDevices(); + } + + // + // Control + + _populate() { + const { + provider, + providerData, + dispatchFetchDevices + } = this.props; + + dispatchFetchDevices({ provider, providerData }); + } + + // + // Listeners + + onRefreshPress = () => { + this._populate(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeviceInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchDevices: PropTypes.func.isRequired, + dispatchClearDevices: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css new file mode 100644 index 000000000..568e35f40 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -0,0 +1,79 @@ +.tether { + z-index: 2000; +} + +.enhancedSelect { + composes: input from 'Components/Form/Input.css'; + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: flex; + align-items: center; + padding: 6px 16px; + width: 100%; + height: 35px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + color: $black; + cursor: default; +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} + +.isDisabled { + opacity: 0.7; + cursor: not-allowed; +} + +.dropdownArrowContainer { + margin-left: 12px; +} + +.dropdownArrowContainerDisabled { + composes: dropdownArrowContainer; + + color: $disabledInputColor; +} + +.optionsContainer { + width: auto; +} + +.options { + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} + +.optionsModal { + display: flex; + justify-content: center; + max-width: 90%; + width: 350px !important; + height: auto !important; +} + +.optionsModalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + justify-content: center; + flex-direction: column; + padding: 10px 0; +} + +.optionsModalScroller { + composes: scroller from 'Components/Scroller/Scroller.css'; + + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js new file mode 100644 index 000000000..a127feaed --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -0,0 +1,411 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; +import * as keyCodes from 'Utilities/Constants/keyCodes'; +import { icons, scrollDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import Measure from 'Components/Measure'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import Scroller from 'Components/Scroller/Scroller'; +import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue'; +import EnhancedSelectInputOption from './EnhancedSelectInputOption'; +import styles from './EnhancedSelectInput.css'; + +const tetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ], + attachment: 'top left', + targetAttachment: 'bottom left' +}; + +function isArrowKey(keyCode) { + return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; +} + +function getSelectedOption(selectedIndex, values) { + return values[selectedIndex]; +} + +function findIndex(startingIndex, direction, values) { + let indexToTest = startingIndex + direction; + + while (indexToTest !== startingIndex) { + if (indexToTest < 0) { + indexToTest = values.length - 1; + } else if (indexToTest >= values.length) { + indexToTest = 0; + } + + if (getSelectedOption(indexToTest, values).isDisabled) { + indexToTest = indexToTest + direction; + } else { + return indexToTest; + } + } +} + +function previousIndex(selectedIndex, values) { + return findIndex(selectedIndex, -1, values); +} + +function nextIndex(selectedIndex, values) { + return findIndex(selectedIndex, 1, values); +} + +function getSelectedIndex(props) { + const { + value, + values + } = props; + + return values.findIndex((v) => { + return v.key === value; + }); +} + +function getKey(selectedIndex, values) { + return values[selectedIndex].key; +} + +class EnhancedSelectInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOpen: false, + selectedIndex: getSelectedIndex(props), + width: 0, + isMobile: isMobileUtil() + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.value !== this.props.value) { + this.setState({ + selectedIndex: getSelectedIndex(this.props) + }); + } + } + + // + // Control + + _setButtonRef = (ref) => { + this._buttonRef = ref; + } + + _setOptionsRef = (ref) => { + this._optionsRef = ref; + } + + _addListener() { + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const button = ReactDOM.findDOMNode(this._buttonRef); + const options = ReactDOM.findDOMNode(this._optionsRef); + + if (!button || this.state.isMobile) { + return; + } + + if ( + !button.contains(event.target) && + options && + !options.contains(event.target) && + this.state.isOpen + ) { + this.setState({ isOpen: false }); + this._removeListener(); + } + } + + onBlur = () => { + this.setState({ + selectedIndex: getSelectedIndex(this.props) + }); + } + + onKeyDown = (event) => { + const { + values + } = this.props; + + const { + isOpen, + selectedIndex + } = this.state; + + const keyCode = event.keyCode; + const newState = {}; + + if (!isOpen) { + if (isArrowKey(keyCode)) { + event.preventDefault(); + newState.isOpen = true; + } + + if ( + selectedIndex == null || + getSelectedOption(selectedIndex, values).isDisabled + ) { + if (keyCode === keyCodes.UP_ARROW) { + newState.selectedIndex = previousIndex(0, values); + } else if (keyCode === keyCodes.DOWN_ARROW) { + newState.selectedIndex = nextIndex(values.length - 1, values); + } + } + + this.setState(newState); + return; + } + + if (keyCode === keyCodes.UP_ARROW) { + event.preventDefault(); + newState.selectedIndex = previousIndex(selectedIndex, values); + } + + if (keyCode === keyCodes.DOWN_ARROW) { + event.preventDefault(); + newState.selectedIndex = nextIndex(selectedIndex, values); + } + + if (keyCode === keyCodes.ENTER) { + event.preventDefault(); + newState.isOpen = false; + this.onSelect(getKey(selectedIndex, values)); + } + + if (keyCode === keyCodes.TAB) { + newState.isOpen = false; + this.onSelect(getKey(selectedIndex, values)); + } + + if (keyCode === keyCodes.ESCAPE) { + event.preventDefault(); + event.stopPropagation(); + newState.isOpen = false; + newState.selectedIndex = getSelectedIndex(this.props); + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + onPress = () => { + if (this.state.isOpen) { + this._removeListener(); + } else { + this._addListener(); + } + + this.setState({ isOpen: !this.state.isOpen }); + } + + onSelect = (value) => { + this.setState({ isOpen: false }); + + this.props.onChange({ + name: this.props.name, + value + }); + } + + onMeasure = ({ width }) => { + this.setState({ width }); + } + + onOptionsModalClose = () => { + this.setState({ isOpen: false }); + } + + // + // Render + + render() { + const { + className, + disabledClassName, + values, + isDisabled, + hasError, + hasWarning, + selectedValueOptions, + selectedValueComponent: SelectedValueComponent, + optionComponent: OptionComponent + } = this.props; + + const { + selectedIndex, + width, + isOpen, + isMobile + } = this.state; + + const selectedOption = getSelectedOption(selectedIndex, values); + + return ( +
+ + + + + {selectedOption ? selectedOption.value : null} + + +
+ +
+ +
+ + { + isOpen && !isMobile && +
+
+ { + values.map((v, index) => { + return ( + + {v.value} + + ); + }) + } +
+
+ } +
+ + { + isMobile && + + + + { + values.map((v, index) => { + return ( + + {v.value} + + ); + }) + } + + + + } +
+ ); + } +} + +EnhancedSelectInput.propTypes = { + className: PropTypes.string, + disabledClassName: PropTypes.string, + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + selectedValueOptions: PropTypes.object.isRequired, + selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, + optionComponent: PropTypes.func, + onChange: PropTypes.func.isRequired +}; + +EnhancedSelectInput.defaultProps = { + className: styles.enhancedSelect, + disabledClassName: styles.isDisabled, + isDisabled: false, + selectedValueOptions: {}, + selectedValueComponent: EnhancedSelectInputSelectedValue, + optionComponent: EnhancedSelectInputOption +}; + +export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css new file mode 100644 index 000000000..2b96de47f --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css @@ -0,0 +1,41 @@ +.option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px; + width: 100%; + cursor: default; + + &:hover { + background-color: #f9f9f9; + } +} + +.isSelected { + background-color: #e2e2e2; + + &.isMobile { + background-color: inherit; + + .iconContainer { + color: $primaryColor; + } + } +} + +.isDisabled { + background-color: #aaa; +} + +.isHidden { + display: none; +} + +.isMobile { + height: 50px; + border-bottom: 1px solid $borderColor; + + &:last-child { + border: none; + } +} diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js new file mode 100644 index 000000000..e1b410c28 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './EnhancedSelectInputOption.css'; + +class EnhancedSelectInputOption extends Component { + + // + // Listeners + + onPress = () => { + const { + id, + onSelect + } = this.props; + + onSelect(id); + } + + // + // Render + + render() { + const { + className, + isSelected, + isDisabled, + isHidden, + isMobile, + children + } = this.props; + + return ( + + {children} + + { + isMobile && +
+ +
+ } + + ); + } +} + +EnhancedSelectInputOption.propTypes = { + className: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool.isRequired, + isHidden: PropTypes.bool.isRequired, + isMobile: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + onSelect: PropTypes.func.isRequired +}; + +EnhancedSelectInputOption.defaultProps = { + className: styles.option, + isDisabled: false, + isHidden: false +}; + +export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css new file mode 100644 index 000000000..6b8b73af9 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css @@ -0,0 +1,7 @@ +.selectedValue { + flex: 1 1 auto; +} + +.isDisabled { + color: $disabledInputColor; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js new file mode 100644 index 000000000..c40ee93c1 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './EnhancedSelectInputSelectedValue.css'; + +function EnhancedSelectInputSelectedValue(props) { + const { + className, + children, + isDisabled + } = props; + + return ( +
+ {children} +
+ ); +} + +EnhancedSelectInputSelectedValue.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node, + isDisabled: PropTypes.bool.isRequired +}; + +EnhancedSelectInputSelectedValue.defaultProps = { + className: styles.selectedValue, + isDisabled: false +}; + +export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Form.css b/frontend/src/Components/Form/Form.css new file mode 100644 index 000000000..52e79aec4 --- /dev/null +++ b/frontend/src/Components/Form/Form.css @@ -0,0 +1,3 @@ +.validationFailures { + margin-bottom: 20px; +} diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js new file mode 100644 index 000000000..c2c67eddf --- /dev/null +++ b/frontend/src/Components/Form/Form.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import styles from './Form.css'; + +function Form({ children, validationErrors, validationWarnings, ...otherProps }) { + return ( +
+ { + validationErrors.length || validationWarnings.length ? +
+ { + validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + }) + } + + { + validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + }) + } +
: + null + } + + {children} +
+ ); +} + +Form.propTypes = { + children: PropTypes.node.isRequired, + validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, + validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +Form.defaultProps = { + validationErrors: [], + validationWarnings: [] +}; + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.css b/frontend/src/Components/Form/FormGroup.css new file mode 100644 index 000000000..ddce8863b --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.css @@ -0,0 +1,28 @@ +.group { + display: flex; + margin-bottom: 20px; +} + +/* Sizes */ + +.extraSmall { + max-width: $formGroupExtraSmallWidth; +} + +.small { + max-width: $formGroupSmallWidth; +} + +.medium { + max-width: $formGroupMediumWidth; +} + +.large { + max-width: $formGroupLargeWidth; +} + +@media only screen and (max-width: $breakpointLarge) { + .group { + display: block; + } +} diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js new file mode 100644 index 000000000..d2e04c350 --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import { sizes } from 'Helpers/Props'; +import styles from './FormGroup.css'; + +function FormGroup(props) { + const { + className, + children, + size, + advancedSettings, + isAdvanced, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ { + map(children, (child) => { + return React.cloneElement(child, childProps); + }) + } +
+ ); +} + +FormGroup.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + size: PropTypes.oneOf(sizes.all).isRequired, + advancedSettings: PropTypes.bool.isRequired, + isAdvanced: PropTypes.bool.isRequired +}; + +FormGroup.defaultProps = { + className: styles.group, + size: sizes.SMALL, + advancedSettings: false, + isAdvanced: false +}; + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputButton.css b/frontend/src/Components/Form/FormInputButton.css new file mode 100644 index 000000000..27a1923be --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.css @@ -0,0 +1,12 @@ +.button { + composes: button from 'Components/Link/Button.css'; + + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.middleButton { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js new file mode 100644 index 000000000..4b6491663 --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import { kinds } from 'Helpers/Props'; +import styles from './FormInputButton.css'; + +function FormInputButton(props) { + const { + className, + canSpin, + isLastButton, + ...otherProps + } = props; + + if (canSpin) { + return ( + + ); + } + + return ( + + ); +} + +SpinnerButton.propTypes = { + className: PropTypes.string.isRequired, + isSpinning: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool, + spinnerIcon: PropTypes.object.isRequired, + children: PropTypes.node +}; + +SpinnerButton.defaultProps = { + className: styles.button, + spinnerIcon: icons.SPINNER +}; + +export default SpinnerButton; diff --git a/frontend/src/Components/Link/SpinnerErrorButton.css b/frontend/src/Components/Link/SpinnerErrorButton.css new file mode 100644 index 000000000..5f4e68545 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.css @@ -0,0 +1,23 @@ +.iconContainer { + composes: spinnerContainer from 'Components/Link/SpinnerButton.css'; +} + +.icon { + z-index: 1; +} + +.label { + composes: label from 'Components/Link/SpinnerButton.css'; +} + +.showIcon { + .iconContainer { + left: 50%; + visibility: visible; + } + + .label { + left: 100%; + opacity: 0; + } +} diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js new file mode 100644 index 000000000..0575db094 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.js @@ -0,0 +1,162 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import styles from './SpinnerErrorButton.css'; + +function getTestResult(error) { + if (!error) { + return { + wasSuccessful: true, + hasWarning: false, + hasError: false + }; + } + + if (error.status !== 400) { + return { + wasSuccessful: false, + hasWarning: false, + hasError: true + }; + } + + const failures = error.responseJSON; + + const hasWarning = _.some(failures, { isWarning: true }); + const hasError = _.some(failures, (failure) => !failure.isWarning); + + return { + wasSuccessful: false, + hasWarning, + hasError + }; +} + +class SpinnerErrorButton extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._testResultTimeout = null; + + this.state = { + wasSuccessful: false, + hasWarning: false, + hasError: false + }; + } + + componentDidUpdate(prevProps) { + const { + isSpinning, + error + } = this.props; + + if (prevProps.isSpinning && !isSpinning) { + const testResult = getTestResult(error); + + this.setState(testResult, () => { + const { + wasSuccessful, + hasWarning, + hasError + } = testResult; + + if (wasSuccessful || hasWarning || hasError) { + this._testResultTimeout = setTimeout(this.resetState, 3000); + } + }); + } + } + + componentWillUnmount() { + if (this._testResultTimeout) { + clearTimeout(this._testResultTimeout); + } + } + + // + // Control + + resetState = () => { + this.setState({ + wasSuccessful: false, + hasWarning: false, + hasError: false + }); + } + + // + // Render + + render() { + const { + isSpinning, + error, + children, + ...otherProps + } = this.props; + + const { + wasSuccessful, + hasWarning, + hasError + } = this.state; + + const showIcon = wasSuccessful || hasWarning || hasError; + + let iconName = icons.CHECK; + let iconKind = kinds.SUCCESS; + + if (hasWarning) { + iconName = icons.WARNING; + iconKind = kinds.WARNING; + } + + if (hasError) { + iconName = icons.DANGER; + iconKind = kinds.DANGER; + } + + return ( + + + { + showIcon && + + + + } + + { + + { + children + } + + } + + + ); + } +} + +SpinnerErrorButton.propTypes = { + isSpinning: PropTypes.bool.isRequired, + error: PropTypes.object, + children: PropTypes.node.isRequired +}; + +export default SpinnerErrorButton; diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js new file mode 100644 index 000000000..a804fafc5 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerIconButton.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from './IconButton'; + +function SpinnerIconButton(props) { + const { + name, + spinningName, + isDisabled, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIconButton.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isDisabled: PropTypes.bool.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIconButton.defaultProps = { + spinningName: icons.SPINNER, + isDisabled: false, + isSpinning: false +}; + +export default SpinnerIconButton; diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css new file mode 100644 index 000000000..fd224b1d6 --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.css @@ -0,0 +1,49 @@ +.loading { + margin-top: 20px; + text-align: center; +} + +.rippleContainer { + position: relative; + display: inline-block; +} + +.ripple:nth-child(0) { + animation-delay: -0.8s; +} + +.ripple:nth-child(1) { + animation-delay: -0.6s; +} + +.ripple:nth-child(2) { + animation-delay: -0.4s; +} + +.ripple:nth-child(3) { + animation-delay: -0.2s; +} + +.ripple { + position: absolute; + border: 2px solid #3a3f51; + border-radius: 100%; + animation: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8); + animation-fill-mode: both; +} + +@keyframes rippleContainer { + 0% { + opacity: 1; + transform: scale(0.1); + } + + 70% { + opacity: 0.7; + transform: scale(1); + } + + 100% { + opacity: 0; + } +} diff --git a/frontend/src/Components/Loading/LoadingIndicator.js b/frontend/src/Components/Loading/LoadingIndicator.js new file mode 100644 index 000000000..5f9a15b1a --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './LoadingIndicator.css'; + +function LoadingIndicator({ className, size }) { + const sizeInPx = `${size}px`; + const width = sizeInPx; + const height = sizeInPx; + + return ( +
+
+
+ +
+ +
+
+
+ ); +} + +LoadingIndicator.propTypes = { + className: PropTypes.string, + size: PropTypes.number +}; + +LoadingIndicator.defaultProps = { + className: styles.loading, + size: 50 +}; + +export default LoadingIndicator; diff --git a/frontend/src/Components/Loading/LoadingMessage.css b/frontend/src/Components/Loading/LoadingMessage.css new file mode 100644 index 000000000..a7b39e76f --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.css @@ -0,0 +1,6 @@ +.loadingMessage { + margin: 50px 10px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} diff --git a/frontend/src/Components/Loading/LoadingMessage.js b/frontend/src/Components/Loading/LoadingMessage.js new file mode 100644 index 000000000..db6cb2b56 --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.js @@ -0,0 +1,20 @@ +import React from 'react'; +import styles from './LoadingMessage.css'; + +const messages = [ + 'Welcome to Radarr Aphrodite Preview. Enjoy' + // TODO Add some messages here +]; + +function LoadingMessage() { + const index = Math.floor(Math.random() * messages.length); + const message = messages[index]; + + return ( +
+ {message} +
+ ); +} + +export default LoadingMessage; diff --git a/frontend/src/Components/Measure.js b/frontend/src/Components/Measure.js new file mode 100644 index 000000000..a2f113de7 --- /dev/null +++ b/frontend/src/Components/Measure.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactMeasure from 'react-measure'; + +class Measure extends Component { + + // + // Lifecycle + + componentWillUnmount() { + this.onMeasure.cancel(); + } + + // + // Listeners + + onMeasure = _.debounce((payload) => { + this.props.onMeasure(payload); + }, 250, { leading: true, trailing: false }) + + // + // Render + + render() { + return ( + + ); + } +} + +Measure.propTypes = { + onMeasure: PropTypes.func.isRequired +}; + +export default Measure; diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css new file mode 100644 index 000000000..34991aed9 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.css @@ -0,0 +1,9 @@ +.filterMenu { + composes: menu from './Menu.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterMenu { + margin-right: 10px; + } +} diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js new file mode 100644 index 000000000..d37876c22 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FilterMenuContent from './FilterMenuContent'; +import Menu from './Menu'; +import ToolbarMenuButton from './ToolbarMenuButton'; +import styles from './FilterMenu.css'; + +class FilterMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isFilterModalOpen: false + }; + } + + // + // Listeners + + onCustomFiltersPress = () => { + this.setState({ isFilterModalOpen: true }); + } + + onFiltersModalClose = () => { + this.setState({ isFilterModalOpen: false }); + } + + // + // Render + + render(props) { + const { + className, + isDisabled, + selectedFilterKey, + filters, + customFilters, + buttonComponent: ButtonComponent, + filterModalConnectorComponent: FilterModalConnectorComponent, + filterModalConnectorComponentProps, + onFilterSelect, + ...otherProps + } = this.props; + + const showCustomFilters = !!FilterModalConnectorComponent; + + return ( +
+ + + + + + + + { + showCustomFilters && + + } +
+ ); + } +} + +FilterMenu.propTypes = { + className: PropTypes.string, + isDisabled: PropTypes.bool.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + buttonComponent: PropTypes.func.isRequired, + filterModalConnectorComponent: PropTypes.func, + filterModalConnectorComponentProps: PropTypes.object, + onFilterSelect: PropTypes.func.isRequired +}; + +FilterMenu.defaultProps = { + className: styles.filterMenu, + isDisabled: false, + buttonComponent: ToolbarMenuButton +}; + +export default FilterMenu; diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js new file mode 100644 index 000000000..7463e2c9e --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuContent.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuContent from './MenuContent'; +import FilterMenuItem from './FilterMenuItem'; +import MenuItem from './MenuItem'; +import MenuItemSeparator from './MenuItemSeparator'; + +class FilterMenuContent extends Component { + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + customFilters, + showCustomFilters, + onFilterSelect, + onCustomFiltersPress, + ...otherProps + } = this.props; + + return ( + + { + filters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + customFilters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + showCustomFilters && + + } + + { + showCustomFilters && + + Custom Filters + + } + + ); + } +} + +FilterMenuContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + showCustomFilters: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onCustomFiltersPress: PropTypes.func.isRequired +}; + +FilterMenuContent.defaultProps = { + showCustomFilters: false +}; + +export default FilterMenuContent; diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js new file mode 100644 index 000000000..d2c495187 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuItem.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import SelectedMenuItem from './SelectedMenuItem'; + +class FilterMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + filterKey, + onPress + } = this.props; + + onPress(filterKey); + } + + // + // Render + + render() { + const { + filterKey, + selectedFilterKey, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +FilterMenuItem.propTypes = { + filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default FilterMenuItem; diff --git a/frontend/src/Components/Menu/Menu.css b/frontend/src/Components/Menu/Menu.css new file mode 100644 index 000000000..9cce48fee --- /dev/null +++ b/frontend/src/Components/Menu/Menu.css @@ -0,0 +1,7 @@ +.tether { + z-index: 2000; +} + +.menu { + position: relative; +} diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js new file mode 100644 index 000000000..da778bb7a --- /dev/null +++ b/frontend/src/Components/Menu/Menu.js @@ -0,0 +1,207 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import { align } from 'Helpers/Props'; +import styles from './Menu.css'; + +const baseTetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] +}; + +const tetherOptions = { + [align.RIGHT]: { + ...baseTetherOptions, + attachment: 'top right', + targetAttachment: 'bottom right' + }, + + [align.LEFT]: { + ...baseTetherOptions, + attachment: 'top left', + targetAttachment: 'bottom left' + } +}; + +class Menu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMenuOpen: false, + maxHeight: 0 + }; + } + + componentDidMount() { + this.setMaxHeight(); + } + + componentWillUnmount() { + this._removeListener(); + } + + // + // Control + + getMaxHeight() { + if (!this.props.enforceMaxHeight) { + return; + } + + const menu = ReactDOM.findDOMNode(this.refs.menu); + + if (!menu) { + return; + } + + const { bottom } = menu.getBoundingClientRect(); + const maxHeight = window.innerHeight - bottom; + + return maxHeight; + } + + setMaxHeight() { + this.setState({ + maxHeight: this.getMaxHeight() + }); + } + + _addListener() { + // Listen to resize events on the window and scroll events + // on all elements to ensure the menu is the best size possible. + // Listen for click events on the window to support closing the + // menu on clicks outside. + + window.addEventListener('resize', this.onWindowResize); + window.addEventListener('scroll', this.onWindowScroll, { capture: true }); + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('resize', this.onWindowResize); + window.removeEventListener('scroll', this.onWindowScroll, { capture: true }); + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const menu = ReactDOM.findDOMNode(this.refs.menu); + const menuContent = ReactDOM.findDOMNode(this.refs.menuContent); + + if (!menu) { + return; + } + + if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) { + this.setState({ isMenuOpen: false }); + this._removeListener(); + } + } + + onWindowResize = () => { + this.setMaxHeight(); + } + + onWindowScroll = () => { + this.setMaxHeight(); + } + + onMenuButtonPress = () => { + const state = { + isMenuOpen: !this.state.isMenuOpen + }; + + if (this.state.isMenuOpen) { + this._removeListener(); + } else { + state.maxHeight = this.getMaxHeight(); + this._addListener(); + } + + this.setState(state); + } + + // + // Render + + render() { + const { + className, + children, + alignMenu + } = this.props; + + const { + maxHeight, + isMenuOpen + } = this.state; + + const childrenArray = React.Children.toArray(children); + const button = React.cloneElement( + childrenArray[0], + { + onPress: this.onMenuButtonPress + } + ); + + const content = React.cloneElement( + childrenArray[1], + { + ref: 'menuContent', + alignMenu, + maxHeight, + isOpen: isMenuOpen + } + ); + + return ( + +
+ {button} +
+ + { + isMenuOpen && + content + } +
+ ); + } +} + +Menu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]), + enforceMaxHeight: PropTypes.bool.isRequired +}; + +Menu.defaultProps = { + className: styles.menu, + alignMenu: align.LEFT, + enforceMaxHeight: true +}; + +export default Menu; diff --git a/frontend/src/Components/Menu/MenuButton.css b/frontend/src/Components/Menu/MenuButton.css new file mode 100644 index 000000000..38812cfb7 --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.css @@ -0,0 +1,21 @@ +.menuButton { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + &::after { + margin-left: 5px; + content: '\25BE'; + } + + &:hover { + color: $toobarButtonHoverColor; + } +} + +.isDisabled { + color: $disabledColor; + + pointer-events: none; +} diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js new file mode 100644 index 000000000..477334a1d --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import Link from 'Components/Link/Link'; +import styles from './MenuButton.css'; + +class MenuButton extends Component { + + // + // Render + + render() { + const { + className, + children, + isDisabled, + onPress, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuButton.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired, + onPress: PropTypes.func +}; + +MenuButton.defaultProps = { + className: styles.menuButton, + isDisabled: false +}; + +export default MenuButton; diff --git a/frontend/src/Components/Menu/MenuContent.css b/frontend/src/Components/Menu/MenuContent.css new file mode 100644 index 000000000..0acc07390 --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.css @@ -0,0 +1,11 @@ +.menuContent { + display: flex; + flex-direction: column; + background-color: $toolbarMenuItemBackgroundColor; + line-height: 20px; +} + +.scroller { + display: flex; + flex-direction: column; +} diff --git a/frontend/src/Components/Menu/MenuContent.js b/frontend/src/Components/Menu/MenuContent.js new file mode 100644 index 000000000..1acacf80f --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './MenuContent.css'; + +class MenuContent extends Component { + + // + // Render + + render() { + const { + className, + children, + maxHeight + } = this.props; + + return ( +
+ + {children} + +
+ ); + } +} + +MenuContent.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + maxHeight: PropTypes.number +}; + +MenuContent.defaultProps = { + className: styles.menuContent +}; + +export default MenuContent; diff --git a/frontend/src/Components/Menu/MenuItem.css b/frontend/src/Components/Menu/MenuItem.css new file mode 100644 index 000000000..bae1a649c --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.css @@ -0,0 +1,19 @@ +.menuItem { + @add-mixin truncate; + + display: block; + flex-shrink: 0; + padding: 10px 20px; + min-width: 150px; + max-width: 250px; + background-color: $toolbarMenuItemBackgroundColor; + color: $menuItemColor; + line-height: 20px; + + &:hover, + &:focus { + background-color: $toolbarMenuItemHoverBackgroundColor; + color: $menuItemHoverColor; + text-decoration: none; + } +} diff --git a/frontend/src/Components/Menu/MenuItem.js b/frontend/src/Components/Menu/MenuItem.js new file mode 100644 index 000000000..ff083450b --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './MenuItem.css'; + +class MenuItem extends Component { + + // + // Render + + render() { + const { + className, + children, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuItem.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +MenuItem.defaultProps = { + className: styles.menuItem +}; + +export default MenuItem; diff --git a/frontend/src/Components/Menu/MenuItemSeparator.css b/frontend/src/Components/Menu/MenuItemSeparator.css new file mode 100644 index 000000000..a867e3153 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.css @@ -0,0 +1,5 @@ +.separator { + overflow: hidden; + height: 1px; + background-color: $themeDarkColor; +} diff --git a/frontend/src/Components/Menu/MenuItemSeparator.js b/frontend/src/Components/Menu/MenuItemSeparator.js new file mode 100644 index 000000000..e586670c9 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './MenuItemSeparator.css'; + +function MenuItemSeparator() { + return ( +
+ ); +} + +export default MenuItemSeparator; diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css new file mode 100644 index 000000000..e6954f600 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from './MenuButton.css'; + + &:hover { + color: #666; + } +} + +.label { + margin-left: 5px; +} diff --git a/frontend/src/Components/Menu/PageMenuButton.js b/frontend/src/Components/Menu/PageMenuButton.js new file mode 100644 index 000000000..abbfc98f8 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './PageMenuButton.css'; + +function PageMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + + + +
+ {text} +
+
+ ); +} + +PageMenuButton.propTypes = { + iconName: PropTypes.object.isRequired, + text: PropTypes.string +}; + +export default PageMenuButton; diff --git a/frontend/src/Components/Menu/SelectedMenuItem.css b/frontend/src/Components/Menu/SelectedMenuItem.css new file mode 100644 index 000000000..739419d69 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.css @@ -0,0 +1,15 @@ +.item { + display: flex; + justify-content: space-between; + white-space: nowrap; +} + +.isSelected { + visibility: visible; + margin-left: 20px; +} + +.isNotSelected { + visibility: hidden; + margin-left: 20px; +} diff --git a/frontend/src/Components/Menu/SelectedMenuItem.js b/frontend/src/Components/Menu/SelectedMenuItem.js new file mode 100644 index 000000000..8b0805c57 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import MenuItem from './MenuItem'; +import styles from './SelectedMenuItem.css'; + +class SelectedMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + onPress + } = this.props; + + onPress(name); + } + + // + // Render + + render() { + const { + children, + selectedIconName, + isSelected, + ...otherProps + } = this.props; + + return ( + +
+ {children} + + +
+
+ ); + } +} + +SelectedMenuItem.propTypes = { + name: PropTypes.string, + children: PropTypes.node.isRequired, + selectedIconName: PropTypes.object.isRequired, + isSelected: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +SelectedMenuItem.defaultProps = { + selectedIconName: icons.CHECK +}; + +export default SelectedMenuItem; diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js new file mode 100644 index 000000000..a9a6a184e --- /dev/null +++ b/frontend/src/Components/Menu/SortMenu.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Menu from 'Components/Menu/Menu'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; + +function SortMenu(props) { + const { + className, + children, + isDisabled, + ...otherProps + } = props; + + return ( + + + {children} + + ); +} + +SortMenu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired +}; + +SortMenu.defaultProps = { + isDisabled: false +}; + +export default SortMenu; diff --git a/frontend/src/Components/Menu/SortMenuItem.js b/frontend/src/Components/Menu/SortMenuItem.js new file mode 100644 index 000000000..e35864ae6 --- /dev/null +++ b/frontend/src/Components/Menu/SortMenuItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import SelectedMenuItem from './SelectedMenuItem'; + +function SortMenuItem(props) { + const { + name, + sortKey, + sortDirection, + ...otherProps + } = props; + + const isSelected = name === sortKey; + + return ( + + ); +} + +SortMenuItem.propTypes = { + name: PropTypes.string, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + onPress: PropTypes.func.isRequired +}; + +SortMenuItem.defaultProps = { + name: null +}; + +export default SortMenuItem; diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css new file mode 100644 index 000000000..c8a905e17 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from './MenuButton.css'; + + width: $toolbarButtonWidth; + height: $toolbarHeight; + text-align: center; +} + +.label { + composes: label from 'Components/Page/Toolbar/PageToolbarButton.css'; +} diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js new file mode 100644 index 000000000..b80d6eaa3 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './ToolbarMenuButton.css'; + +function ToolbarMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + +
+ + +
+ {text} +
+
+
+ ); +} + +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 && + + } + + + {confirmLabel} + + + + + ); +} + +ConfirmModal.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + kind: PropTypes.oneOf(kinds.all), + size: PropTypes.oneOf(sizes.all), + title: PropTypes.string.isRequired, + message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + confirmLabel: PropTypes.string, + cancelLabel: PropTypes.string, + hideCancelButton: PropTypes.bool, + isSpinning: PropTypes.bool.isRequired, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +ConfirmModal.defaultProps = { + kind: kinds.PRIMARY, + size: sizes.MEDIUM, + confirmLabel: 'OK', + cancelLabel: 'Cancel', + isSpinning: false +}; + +export default ConfirmModal; diff --git a/frontend/src/Components/Modal/Modal.css b/frontend/src/Components/Modal/Modal.css new file mode 100644 index 000000000..a9b2a27ae --- /dev/null +++ b/frontend/src/Components/Modal/Modal.css @@ -0,0 +1,92 @@ +.modalContainer { + position: absolute; + top: 0; + z-index: 1000; + width: 100%; + height: 100%; +} + +.modalBackdrop { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: $modalBackdropBackgroundColor; + opacity: 1; +} + +.modal { + position: relative; + display: flex; + max-height: 90%; + border-radius: 6px; + opacity: 1; +} + +.modalOpen { + /* Prevent the body from scrolling when the modal is open */ + overflow: hidden !important; +} + +/* + * Sizes + */ + +.small { + composes: modal; + + width: 480px; +} + +.medium { + composes: modal; + + width: 720px; +} + +.large { + composes: modal; + + width: 1080px; +} + +.extraLarge { + composes: modal; + + width: 1280px; +} + +@media only screen and (max-width: $breakpointExtraLarge) { + .modal.extraLarge { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .modal.large { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointMedium) { + .modal.small, + .modal.medium { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .modalContainer { + position: fixed; + } + + .modal.small, + .modal.medium, + .modal.large, + .modal.extraLarge { + max-height: 100%; + width: 100%; + height: 100% !important; + } +} diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js new file mode 100644 index 000000000..a9de82c6d --- /dev/null +++ b/frontend/src/Components/Modal/Modal.js @@ -0,0 +1,215 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import elementClass from 'element-class'; +import getUniqueElememtId from 'Utilities/getUniqueElementId'; +import * as keyCodes from 'Utilities/Constants/keyCodes'; +import { sizes } from 'Helpers/Props'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import ModalError from './ModalError'; +import styles from './Modal.css'; + +const openModals = []; + +function removeFromOpenModals(id) { + const index = openModals.indexOf(id); + + if (index >= 0) { + openModals.splice(index, 1); + } +} + +class Modal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._node = document.getElementById('modal-root'); + this._backgroundRef = null; + this._modalId = getUniqueElememtId(); + } + + componentDidMount() { + if (this.props.isOpen) { + this._openModal(); + } + } + + componentDidUpdate(prevProps) { + const { + isOpen + } = this.props; + + if (!prevProps.isOpen && isOpen) { + this._openModal(); + } else if (prevProps.isOpen && !isOpen) { + this._closeModal(); + } + } + + componentWillUnmount() { + if (this.props.isOpen) { + this._closeModal(); + } + } + + // + // Control + + _setBackgroundRef = (ref) => { + this._backgroundRef = ref; + } + + _openModal() { + openModals.push(this._modalId); + window.addEventListener('keydown', this.onKeyDown); + + if (openModals.length === 1) { + elementClass(document.body).add(styles.modalOpen); + } + } + + _closeModal() { + removeFromOpenModals(this._modalId); + window.removeEventListener('keydown', this.onKeyDown); + + if (openModals.length === 0) { + elementClass(document.body).remove(styles.modalOpen); + } + } + + _isBackdropTarget(event) { + const targetElement = this._findEventTarget(event); + + if (targetElement) { + const backgroundElement = ReactDOM.findDOMNode(this._backgroundRef); + + return backgroundElement.isEqualNode(targetElement); + } + + return false; + } + + _findEventTarget(event) { + const changedTouches = event.changedTouches; + + if (!changedTouches) { + return event.target; + } + + if (changedTouches.length === 1) { + const touch = changedTouches[0]; + + return document.elementFromPoint(touch.clientX, touch.clientY); + } + } + + // + // Listeners + + onBackdropBeginPress = (event) => { + this._isBackdropPressed = this._isBackdropTarget(event); + } + + onBackdropEndPress = (event) => { + const { + closeOnBackgroundClick, + onModalClose + } = this.props; + + if ( + this._isBackdropPressed && + this._isBackdropTarget(event) && + closeOnBackgroundClick + ) { + onModalClose(); + } + + this._isBackdropPressed = false; + } + + onKeyDown = (event) => { + const keyCode = event.keyCode; + + if (keyCode === keyCodes.ESCAPE) { + if (openModals.indexOf(this._modalId) === openModals.length - 1) { + event.preventDefault(); + event.stopPropagation(); + + this.props.onModalClose(); + } + } + } + + // + // Render + + render() { + const { + className, + style, + backdropClassName, + size, + children, + isOpen, + onModalClose + } = this.props; + + if (!isOpen) { + return null; + } + + return ReactDOM.createPortal( +
+
+
+ + {children} + +
+
+
, + this._node + ); + } +} + +Modal.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + backdropClassName: PropTypes.string, + size: PropTypes.oneOf(sizes.all), + children: PropTypes.node, + isOpen: PropTypes.bool.isRequired, + closeOnBackgroundClick: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +Modal.defaultProps = { + className: styles.modal, + backdropClassName: styles.modalBackdrop, + size: sizes.LARGE, + closeOnBackgroundClick: true +}; + +export default Modal; diff --git a/frontend/src/Components/Modal/ModalBody.css b/frontend/src/Components/Modal/ModalBody.css new file mode 100644 index 000000000..ebeef29de --- /dev/null +++ b/frontend/src/Components/Modal/ModalBody.css @@ -0,0 +1,12 @@ +.modalBody { + flex: 1 0 1px; + padding: $modalBodyPadding; +} + +.modalScroller { + flex-grow: 1; +} + +.innerModalBody { + padding: $modalBodyPadding; +} diff --git a/frontend/src/Components/Modal/ModalBody.js b/frontend/src/Components/Modal/ModalBody.js new file mode 100644 index 000000000..a35f2ecf5 --- /dev/null +++ b/frontend/src/Components/Modal/ModalBody.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './ModalBody.css'; + +class ModalBody extends Component { + + // + // Render + + render() { + const { + innerClassName, + scrollDirection, + children, + ...otherProps + } = this.props; + + let className = this.props.className; + const hasScroller = scrollDirection !== scrollDirections.NONE; + + if (!className) { + className = hasScroller ? styles.modalScroller : styles.modalBody; + } + + return ( + + { + hasScroller ? +
+ {children} +
: + children + } +
+ ); + } + +} + +ModalBody.propTypes = { + className: PropTypes.string, + innerClassName: PropTypes.string, + children: PropTypes.node, + scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]) +}; + +ModalBody.defaultProps = { + innerClassName: styles.innerModalBody, + scrollDirection: scrollDirections.VERTICAL +}; + +export default ModalBody; diff --git a/frontend/src/Components/Modal/ModalContent.css b/frontend/src/Components/Modal/ModalContent.css new file mode 100644 index 000000000..afd798dfa --- /dev/null +++ b/frontend/src/Components/Modal/ModalContent.css @@ -0,0 +1,23 @@ +.modalContent { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; + background-color: $modalBackgroundColor; +} + +.closeButton { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 60px; + height: 60px; + text-align: center; + line-height: 60px; + + &:hover { + color: $modalCloseButtonHoverColor; + } +} diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js new file mode 100644 index 000000000..655046fe4 --- /dev/null +++ b/frontend/src/Components/Modal/ModalContent.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './ModalContent.css'; + +function ModalContent(props) { + const { + className, + children, + showCloseButton, + onModalClose, + ...otherProps + } = props; + + return ( +
+ { + showCloseButton && + + + + } + + {children} +
+ ); +} + +ModalContent.propTypes = { + className: PropTypes.string, + children: PropTypes.node, + showCloseButton: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +ModalContent.defaultProps = { + className: styles.modalContent, + showCloseButton: true +}; + +export default ModalContent; diff --git a/frontend/src/Components/Modal/ModalError.css b/frontend/src/Components/Modal/ModalError.css new file mode 100644 index 000000000..54dbdbc63 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.css @@ -0,0 +1,15 @@ +.message { + composes: message from 'Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-bottom: 30px; + font-weight: normal; + font-size: 26px; +} + +.details { + composes: details from 'Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-top: 20px; +} diff --git a/frontend/src/Components/Modal/ModalError.js b/frontend/src/Components/Modal/ModalError.js new file mode 100644 index 000000000..df99a5b32 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ModalError.css'; + +function ModalError(props) { + const { + onModalClose, + ...otherProps + } = props; + + return ( + + + Error + + + + + + + + + + ); +} + +ModalError.propTypes = { + onModalClose: PropTypes.func.isRequired +}; + +export default ModalError; diff --git a/frontend/src/Components/Modal/ModalFooter.css b/frontend/src/Components/Modal/ModalFooter.css new file mode 100644 index 000000000..3b817d2bf --- /dev/null +++ b/frontend/src/Components/Modal/ModalFooter.css @@ -0,0 +1,23 @@ +.modalFooter { + display: flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + padding: 15px 30px; + border-top: 1px solid $borderColor; + + a, + button { + margin-left: 10px; + + &:first-child { + margin-left: 0; + } + } +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + padding: 15px; + } +} diff --git a/frontend/src/Components/Modal/ModalFooter.js b/frontend/src/Components/Modal/ModalFooter.js new file mode 100644 index 000000000..0cf8811d3 --- /dev/null +++ b/frontend/src/Components/Modal/ModalFooter.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './ModalFooter.css'; + +class ModalFooter extends Component { + + // + // Render + + render() { + const { + children, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +ModalFooter.propTypes = { + children: PropTypes.node +}; + +export default ModalFooter; diff --git a/frontend/src/Components/Modal/ModalHeader.css b/frontend/src/Components/Modal/ModalHeader.css new file mode 100644 index 000000000..eab77a9f8 --- /dev/null +++ b/frontend/src/Components/Modal/ModalHeader.css @@ -0,0 +1,8 @@ +.modalHeader { + @add-mixin truncate; + + flex-shrink: 0; + padding: 15px 50px 15px 30px; + border-bottom: 1px solid $borderColor; + font-size: 18px; +} diff --git a/frontend/src/Components/Modal/ModalHeader.js b/frontend/src/Components/Modal/ModalHeader.js new file mode 100644 index 000000000..52879b57d --- /dev/null +++ b/frontend/src/Components/Modal/ModalHeader.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './ModalHeader.css'; + +class ModalHeader extends Component { + + // + // Render + + render() { + const { + children, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +ModalHeader.propTypes = { + children: PropTypes.node +}; + +export default ModalHeader; diff --git a/frontend/src/Components/MonitorToggleButton.css b/frontend/src/Components/MonitorToggleButton.css new file mode 100644 index 000000000..794af1e98 --- /dev/null +++ b/frontend/src/Components/MonitorToggleButton.css @@ -0,0 +1,11 @@ +.toggleButton { + composes: button from 'Components/Link/IconButton.css'; + + padding: 0; + font-size: inherit; +} + +.isDisabled { + color: $disabledColor; + cursor: not-allowed; +} diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js new file mode 100644 index 000000000..15ac3d7fe --- /dev/null +++ b/frontend/src/Components/MonitorToggleButton.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import styles from './MonitorToggleButton.css'; + +function getTooltip(monitored, isDisabled) { + if (isDisabled) { + return 'Cannot toogle monitored state when series is unmonitored'; + } + + if (monitored) { + return 'Monitored, click to unmonitor'; + } + + return 'Unmonitored, click to monitor'; +} + +class MonitorToggleButton extends Component { + + // + // Listeners + + onPress = (event) => { + const shiftKey = event.nativeEvent.shiftKey; + + this.props.onPress(!this.props.monitored, { shiftKey }); + } + + // + // Render + + render() { + const { + className, + monitored, + isDisabled, + isSaving, + size, + ...otherProps + } = this.props; + + const iconName = monitored ? icons.MONITORED : icons.UNMONITORED; + + return ( + + ); + } +} + +MonitorToggleButton.propTypes = { + className: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + size: PropTypes.number, + isDisabled: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +MonitorToggleButton.defaultProps = { + className: styles.toggleButton, + isDisabled: false, + isSaving: false +}; + +export default MonitorToggleButton; diff --git a/frontend/src/Components/NotFound.css b/frontend/src/Components/NotFound.css new file mode 100644 index 000000000..9aaf1114f --- /dev/null +++ b/frontend/src/Components/NotFound.css @@ -0,0 +1,14 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.image { + height: 350px; +} diff --git a/frontend/src/Components/NotFound.js b/frontend/src/Components/NotFound.js new file mode 100644 index 000000000..cdd6eda2c --- /dev/null +++ b/frontend/src/Components/NotFound.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import styles from './NotFound.css'; + +function NotFound({ message }) { + return ( + +
+
+ {message} +
+ + +
+
+ ); +} + +NotFound.propTypes = { + message: PropTypes.string.isRequired +}; + +NotFound.defaultProps = { + message: 'You must be lost, nothing to see here.' +}; + +export default NotFound; diff --git a/frontend/src/Components/Page/ErrorPage.css b/frontend/src/Components/Page/ErrorPage.css new file mode 100644 index 000000000..e62a82a6b --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.css @@ -0,0 +1,12 @@ +.page { + composes: page from './Page.css'; + + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.version { + margin-top: 20px; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js new file mode 100644 index 000000000..018e98ca1 --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import styles from './ErrorPage.css'; + +function ErrorPage(props) { + const { + version, + isLocalStorageSupported, + moviesError, + customFiltersError, + tagsError, + qualityProfilesError, + uiSettingsError + } = props; + + let errorMessage = 'Failed to load Radarr'; + + if (!isLocalStorageSupported) { + errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.'; + } else if (moviesError) { + errorMessage = getErrorMessage(moviesError, 'Failed to load movie from API'); + } else if (customFiltersError) { + errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API'); + } else if (tagsError) { + errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API'); + } else if (qualityProfilesError) { + errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API'); + } else if (uiSettingsError) { + errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API'); + } + + return ( +
+
+ {errorMessage} +
+ +
+ Version {version} +
+
+ ); +} + +ErrorPage.propTypes = { + version: PropTypes.string.isRequired, + isLocalStorageSupported: PropTypes.bool.isRequired, + moviesError: PropTypes.object, + customFiltersError: PropTypes.object, + tagsError: PropTypes.object, + qualityProfilesError: PropTypes.object, + uiSettingsError: PropTypes.object +}; + +export default ErrorPage; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js new file mode 100644 index 000000000..a1d106b58 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector'; + +function KeyboardShortcutsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +KeyboardShortcutsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModal; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css new file mode 100644 index 000000000..4425e0e0d --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css @@ -0,0 +1,15 @@ +.shortcut { + display: flex; + justify-content: space-between; + padding: 5px 20px; + font-size: 18px; +} + +.key { + padding: 2px 4px; + border-radius: 3px; + background-color: $defaultColor; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25); + color: $white; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js new file mode 100644 index 000000000..9c07e047c --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { shortcuts } from 'Components/keyboardShortcuts'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './KeyboardShortcutsModalContent.css'; + +function getShortcuts() { + const allShortcuts = []; + + Object.keys(shortcuts).forEach((key) => { + allShortcuts.push(shortcuts[key]); + }); + + return allShortcuts; +} + +function getShortcutKey(combo, isOsx) { + const comboMatch = combo.match(/(.+?)\+(.)/); + + if (!comboMatch) { + return combo; + } + + const modifier = comboMatch[1]; + const key = comboMatch[2]; + let osModifier = modifier; + + if (modifier === 'mod') { + osModifier = isOsx ? 'cmd' : 'ctrl'; + } + + return `${osModifier} + ${key}`; +} + +function KeyboardShortcutsModalContent(props) { + const { + isOsx, + onModalClose + } = props; + + const allShortcuts = getShortcuts(); + + return ( + + + Keyboard Shortcuts + + + + { + allShortcuts.map((shortcut) => { + return ( +
+
+ {getShortcutKey(shortcut.key, isOsx)} +
+ +
+ {shortcut.name} +
+
+ ); + }) + } +
+ + + + +
+ ); +} + +KeyboardShortcutsModalContent.propTypes = { + isOsx: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModalContent; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js new file mode 100644 index 000000000..d80877153 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent'; + +function createMapStateToProps() { + return createSelector( + createSystemStatusSelector(), + (systemStatus) => { + return { + isOsx: systemStatus.isOsx + }; + } + ); +} + +export default connect(createMapStateToProps)(KeyboardShortcutsModalContent); diff --git a/frontend/src/Components/Page/Header/MovieSearchInput.css b/frontend/src/Components/Page/Header/MovieSearchInput.css new file mode 100644 index 000000000..10eefae1e --- /dev/null +++ b/frontend/src/Components/Page/Header/MovieSearchInput.css @@ -0,0 +1,96 @@ +.wrapper { + display: flex; + align-items: center; +} + +.input { + margin-left: 8px; + width: 200px; + border: none; + border-bottom: solid 1px $white; + border-radius: 0; + background-color: transparent; + box-shadow: none; + color: $white; + transition: border 0.3s ease-out; + + &::placeholder { + color: $white; + transition: color 0.3s ease-out; + } + + &:focus { + outline: 0; + border-bottom-color: transparent; + + &::placeholder { + color: transparent; + } + } +} + +.container { + position: relative; + flex-grow: 1; +} + +.movieContainer { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.containerOpen { + .movieContainer { + position: absolute; + top: 42px; + z-index: 1; + overflow-y: auto; + min-width: 100%; + max-height: 230px; + border: 1px solid $themeDarkColor; + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: $themeDarkColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + color: $menuItemColor; + } +} + +.list { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} + +.listItem { + padding: 0 16px; + white-space: nowrap; +} + +.highlighted { + background-color: $themeLightColor; +} + +.sectionTitle { + padding: 5px 8px; + color: $disabledColor; +} + +.addNewSeriesSuggestion { + padding: 0 3px; + cursor: pointer; +} + +@media only screen and (max-width: $breakpointSmall) { + .input { + min-width: 150px; + max-width: 200px; + } + + .container { + min-width: 0; + max-width: 200px; + } +} diff --git a/frontend/src/Components/Page/Header/MovieSearchInput.js b/frontend/src/Components/Page/Header/MovieSearchInput.js new file mode 100644 index 000000000..fa49f0716 --- /dev/null +++ b/frontend/src/Components/Page/Header/MovieSearchInput.js @@ -0,0 +1,264 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import jdu from 'jdu'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import MovieSearchResult from './MovieSearchResult'; +import styles from './MovieSearchInput.css'; + +const ADD_NEW_TYPE = 'addNew'; + +class MovieSearchInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._autosuggest = null; + + this.state = { + value: '', + suggestions: [] + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.MOVIE_SEARCH_INPUT.key, this.focusInput); + } + + // + // Control + + setAutosuggestRef = (ref) => { + this._autosuggest = ref; + } + + focusInput = (event) => { + event.preventDefault(); + this._autosuggest.input.focus(); + } + + getSectionSuggestions(section) { + return section.suggestions; + } + + renderSectionTitle(section) { + return ( +
+ {section.title} +
+ ); + } + + getSuggestionValue({ title }) { + return title; + } + + renderSuggestion(item, { query }) { + if (item.type === ADD_NEW_TYPE) { + return ( +
+ Search for {query} +
+ ); + } + + return ( + + ); + } + + goToMovie(movie) { + this.setState({ value: '' }); + this.props.onGoToMovie(movie.titleSlug); + } + + reset() { + this.setState({ + value: '', + suggestions: [] + }); + } + + // + // Listeners + + onChange = (event, { newValue, method }) => { + if (method === 'up' || method === 'down') { + return; + } + + this.setState({ value: newValue }); + } + + onKeyDown = (event) => { + if (event.key !== 'Tab' && event.key !== 'Enter') { + return; + } + + const { + suggestions, + value + } = this.state; + + const { + highlightedSectionIndex, + highlightedSuggestionIndex + } = this._autosuggest.state; + + if (!suggestions.length || highlightedSectionIndex) { + this.props.onGoToAddNewMovie(value); + this._autosuggest.input.blur(); + this.reset(); + + return; + } + + // If an suggestion is not selected go to the first series, + // otherwise go to the selected series. + + if (highlightedSuggestionIndex == null) { + this.goToMovie(suggestions[0]); + } else { + this.goToMovie(suggestions[highlightedSuggestionIndex]); + } + + this._autosuggest.input.blur(); + this.reset(); + } + + onBlur = () => { + this.reset(); + } + + onSuggestionsFetchRequested = ({ value }) => { + const lowerCaseValue = jdu.replace(value).toLowerCase(); + + const suggestions = this.props.movie.filter((movie) => { + // Check the title first and if there isn't a match fallback to + // the alternate titles and finally the tags. + + if (value.length === 1) { + return ( + movie.cleanTitle.startsWith(lowerCaseValue) || + movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.startsWith(lowerCaseValue)) || + movie.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue)) + ); + } + + return ( + movie.cleanTitle.contains(lowerCaseValue) || + movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) || + movie.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue)) + ); + }); + + this.setState({ suggestions }); + } + + onSuggestionsClearRequested = () => { + this.setState({ + suggestions: [] + }); + } + + onSuggestionSelected = (event, { suggestion }) => { + if (suggestion.type === ADD_NEW_TYPE) { + this.props.onGoToAddNewMovie(this.state.value); + } else { + this.goToMovie(suggestion); + } + } + + // + // Render + + render() { + const { + value, + suggestions + } = this.state; + + const suggestionGroups = []; + + if (suggestions.length) { + suggestionGroups.push({ + title: 'Existing Movie', + suggestions + }); + } + + suggestionGroups.push({ + title: 'Add New Movie', + suggestions: [ + { + type: ADD_NEW_TYPE, + title: value + } + ] + }); + + const inputProps = { + ref: this.setInputRef, + className: styles.input, + name: 'seriesSearch', + value, + placeholder: 'Search', + autoComplete: 'off', + spellCheck: false, + onChange: this.onChange, + onKeyDown: this.onKeyDown, + onBlur: this.onBlur, + onFocus: this.onFocus + }; + + const theme = { + container: styles.container, + containerOpen: styles.containerOpen, + suggestionsContainer: styles.movieContainer, + suggestionsList: styles.list, + suggestion: styles.listItem, + suggestionHighlighted: styles.highlighted + }; + + return ( +
+ + + +
+ ); + } +} + +MovieSearchInput.propTypes = { + movie: PropTypes.arrayOf(PropTypes.object).isRequired, + onGoToMovie: PropTypes.func.isRequired, + onGoToAddNewMovie: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(MovieSearchInput); diff --git a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js new file mode 100644 index 000000000..10f3f52d7 --- /dev/null +++ b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js @@ -0,0 +1,98 @@ +import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; +import { createSelector } from 'reselect'; +import jdu from 'jdu'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import MovieSearchInput from './MovieSearchInput'; + +function createCleanTagsSelector() { + return createSelector( + createTagsSelector(), + (tags) => { + return tags.map((tag) => { + const { + id, + label + } = tag; + + return { + id, + label, + cleanLabel: jdu.replace(label).toLowerCase() + }; + }); + } + ); +} + +function createCleanMovieSelector() { + return createSelector( + createAllMoviesSelector(), + createCleanTagsSelector(), + (allMovies, allTags) => { + return allMovies.map((movie) => { + const { + title, + titleSlug, + sortTitle, + images, + alternateTitles = [], + tags = [] + } = movie; + + return { + title, + titleSlug, + sortTitle, + images, + cleanTitle: jdu.replace(title).toLowerCase(), + alternateTitles: alternateTitles.map((alternateTitle) => { + return { + title: alternateTitle.title, + sortTitle: alternateTitle.sortTitle, + cleanTitle: jdu.replace(alternateTitle.title).toLowerCase() + }; + }), + tags: tags.map((id) => { + return allTags.find((tag) => tag.id === id); + }) + }; + }).sort((a, b) => { + if (a.sortTitle < b.sortTitle) { + return -1; + } + if (a.sortTitle > b.sortTitle) { + return 1; + } + + return 0; + }); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createCleanMovieSelector(), + (movie) => { + return { + movie + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onGoToMovie(titleSlug) { + dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`)); + }, + + onGoToAddNewMovie(query) { + dispatch(push(`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(query)}`)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchInput); diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.css b/frontend/src/Components/Page/Header/MovieSearchResult.css new file mode 100644 index 000000000..29edc382b --- /dev/null +++ b/frontend/src/Components/Page/Header/MovieSearchResult.css @@ -0,0 +1,38 @@ +.result { + display: flex; + padding: 3px; + cursor: pointer; +} + +.poster { + width: 35px; + height: 50px; +} + +.titles { + flex: 1 1 1px; +} + +.title { + flex: 1 1 1px; + margin-left: 5px; +} + +.alternateTitle { + composes: title; + + color: $disabledColor; + font-size: $smallFontSize; +} + +.tagContainer { + composes: title; +} + +@media only screen and (max-width: $breakpointSmall) { + .titles, + .title, + .alternateTitle { + @add-mixin truncate; + } +} diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.js b/frontend/src/Components/Page/Header/MovieSearchResult.js new file mode 100644 index 000000000..83211a766 --- /dev/null +++ b/frontend/src/Components/Page/Header/MovieSearchResult.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import MoviePoster from 'Movie/MoviePoster'; +import styles from './MovieSearchResult.css'; + +function findMatchingAlternateTitle(alternateTitles, cleanQuery) { + return alternateTitles.find((alternateTitle) => { + return alternateTitle.cleanTitle.contains(cleanQuery); + }); +} + +function getMatchingTag(tags, cleanQuery) { + return tags.find((tag) => { + return tag.cleanLabel.contains(cleanQuery); + }); +} + +function MovieSearchResult(props) { + const { + cleanQuery, + title, + cleanTitle, + images, + alternateTitles, + tags + } = props; + + const titleContains = cleanTitle.contains(cleanQuery); + let alternateTitle = null; + let tag = null; + + if (!titleContains) { + alternateTitle = findMatchingAlternateTitle(alternateTitles, cleanQuery); + } + + if (!titleContains && !alternateTitle) { + tag = getMatchingTag(tags, cleanQuery); + } + + return ( +
+ + +
+
+ {title} +
+ + { + !!alternateTitle && +
+ {alternateTitle.title} +
+ } + + { + !!tag && +
+ +
+ } +
+
+ ); +} + +MovieSearchResult.propTypes = { + cleanQuery: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + cleanTitle: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default MovieSearchResult; diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css new file mode 100644 index 000000000..a3208ca66 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.css @@ -0,0 +1,65 @@ +.header { + z-index: 3; + display: flex; + align-items: center; + flex: 0 0 auto; + height: $headerHeight; + background-color: #464b51; + color: $white; +} + +.logoContainer { + display: flex; + justify-content: center; + flex: 0 0 $sidebarWidth; +} + +.logoFull { + width: 144px; + height: 48px; +} + +.logo { + width: 32px; + height: 32px; +} + +.sidebarToggleContainer { + display: none; + justify-content: center; + flex: 0 0 45px; + margin-right: 14px; +} + +.right { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.donate { + composes: link from 'Components/Link/Link.css'; + + width: 30px; + color: $themeRed; + text-align: center; + line-height: 60px; + + &:hover { + color: #9c1f30; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .logoContainer { + flex: 0 0 60px; + } + + .sidebarToggleContainer { + display: flex; + } + + .donate { + display: none; + } +} diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js new file mode 100644 index 000000000..1847d937f --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import MovieSearchInputConnector from './MovieSearchInputConnector'; +import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector'; +import KeyboardShortcutsModal from './KeyboardShortcutsModal'; +import styles from './PageHeader.css'; + +class PageHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props); + + this.state = { + isKeyboardShortcutsModalOpen: false + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal); + } + + // + // Control + + onOpenKeyboardShortcutsModal = () => { + this.setState({ isKeyboardShortcutsModalOpen: true }); + } + + // + // Listeners + + onKeyboardShortcutsModalClose = () => { + this.setState({ isKeyboardShortcutsModalOpen: false }); + } + + // + // Render + + render() { + const { + onSidebarToggle, + isSmallScreen + } = this.props; + + return ( +
+
+ + + +
+ +
+ +
+ + + +
+ + +
+ + +
+ ); + } +} + +PageHeader.propTypes = { + onSidebarToggle: PropTypes.func.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(PageHeader); diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css new file mode 100644 index 000000000..e27ad883e --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css @@ -0,0 +1,21 @@ +.menuButton { + margin-right: 15px; + width: 30px; + height: 60px; + text-align: center; + + &:hover { + color: $themeDarkColor; + } +} + +.itemIcon { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + .menuButton { + margin-right: 5px; + } +} + diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js new file mode 100644 index 000000000..8ad7c8409 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import MenuItemSeparator from 'Components/Menu/MenuItemSeparator'; +import styles from './PageHeaderActionsMenu.css'; + +function PageHeaderActionsMenu(props) { + const { + formsAuth, + onKeyboardShortcutsPress, + onRestartPress, + onShutdownPress + } = props; + + return ( +
+ + + + + + + + + Keyboard Shortcuts + + + + + + + Restart + + + + + Shutdown + + + { + formsAuth && +
+ } + + { + formsAuth && + + + Logout + + } + +
+
+ ); +} + +PageHeaderActionsMenu.propTypes = { + formsAuth: PropTypes.bool.isRequired, + onKeyboardShortcutsPress: PropTypes.func.isRequired, + onRestartPress: PropTypes.func.isRequired, + onShutdownPress: PropTypes.func.isRequired +}; + +export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js new file mode 100644 index 000000000..66d131521 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { restart, shutdown } from 'Store/Actions/systemActions'; +import PageHeaderActionsMenu from './PageHeaderActionsMenu'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.status, + (status) => { + return { + formsAuth: status.item.authentication === 'forms' + }; + } + ); +} + +const mapDispatchToProps = { + restart, + shutdown +}; + +class PageHeaderActionsMenuConnector extends Component { + + // + // Listeners + + onRestartPress = () => { + this.props.restart(); + } + + onShutdownPress = () => { + this.props.shutdown(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +PageHeaderActionsMenuConnector.propTypes = { + restart: PropTypes.func.isRequired, + shutdown: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector); diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css new file mode 100644 index 000000000..dd5852e61 --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.css @@ -0,0 +1,3 @@ +.page { + composes: page from './Page.css'; +} diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js new file mode 100644 index 000000000..398b70c4b --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.js @@ -0,0 +1,15 @@ +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import LoadingMessage from 'Components/Loading/LoadingMessage'; +import styles from './LoadingPage.css'; + +function LoadingPage() { + return ( +
+ + +
+ ); +} + +export default LoadingPage; diff --git a/frontend/src/Components/Page/Page.css b/frontend/src/Components/Page/Page.css new file mode 100644 index 000000000..9facbfc22 --- /dev/null +++ b/frontend/src/Components/Page/Page.css @@ -0,0 +1,18 @@ +.page { + display: flex; + flex-direction: column; + height: 100%; +} + +.main { + position: relative; /* need this to position inner content - is this really needed? */ + display: flex; + flex: 1 1 auto; +} + +@media only screen and (max-width: $breakpointSmall) { + .page { + flex-grow: 1; + height: initial; + } +} diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js new file mode 100644 index 000000000..2bb59c532 --- /dev/null +++ b/frontend/src/Components/Page/Page.js @@ -0,0 +1,136 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import SignalRConnector from 'Components/SignalRConnector'; +import ColorImpairedContext from 'App/ColorImpairedContext'; +import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; +import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; +import PageHeader from './Header/PageHeader'; +import PageSidebar from './Sidebar/PageSidebar'; +import styles from './Page.css'; + +class Page extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isUpdatedModalOpen: false, + isConnectionLostModalOpen: false + }; + } + + componentDidMount() { + window.addEventListener('resize', this.onResize); + } + + componentDidUpdate(prevProps) { + const { + isDisconnected, + isUpdated + } = this.props; + + if (!prevProps.isUpdated && isUpdated) { + this.setState({ isUpdatedModalOpen: true }); + } + + if (prevProps.isDisconnected !== isDisconnected) { + this.setState({ isConnectionLostModalOpen: isDisconnected }); + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.onResize); + } + + // + // Listeners + + onResize = () => { + this.props.onResize({ + width: window.innerWidth, + height: window.innerHeight + }); + } + + onUpdatedModalClose = () => { + this.setState({ isUpdatedModalOpen: false }); + } + + onConnectionLostModalClose = () => { + this.setState({ isConnectionLostModalOpen: false }); + } + + // + // Render + + render() { + const { + className, + location, + children, + isSmallScreen, + isSidebarVisible, + enableColorImpairedMode, + onSidebarToggle, + onSidebarVisibleChange + } = this.props; + + return ( + +
+ + + + +
+ + + {children} +
+ + + + +
+
+ ); + } +} + +Page.propTypes = { + className: PropTypes.string, + location: locationShape.isRequired, + children: PropTypes.node.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + isUpdated: PropTypes.bool.isRequired, + isDisconnected: PropTypes.bool.isRequired, + enableColorImpairedMode: PropTypes.bool.isRequired, + onResize: PropTypes.func.isRequired, + onSidebarToggle: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +Page.defaultProps = { + className: styles.page +}; + +export default Page; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js new file mode 100644 index 000000000..a32bbcef7 --- /dev/null +++ b/frontend/src/Components/Page/PageConnector.js @@ -0,0 +1,197 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; +import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; +import { fetchMovies } from 'Store/Actions/movieActions'; +import { fetchTags } from 'Store/Actions/tagActions'; +import { fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import ErrorPage from './ErrorPage'; +import LoadingPage from './LoadingPage'; +import Page from './Page'; + +function testLocalStorage() { + const key = 'radarrTest'; + + try { + localStorage.setItem(key, key); + localStorage.removeItem(key); + + return true; + } catch (e) { + return false; + } +} + +function createMapStateToProps() { + return createSelector( + (state) => state.movies, + (state) => state.customFilters, + (state) => state.tags, + (state) => state.settings.ui, + (state) => state.settings.qualityProfiles, + (state) => state.app, + createDimensionsSelector(), + ( + movies, + customFilters, + tags, + uiSettings, + qualityProfiles, + app, + dimensions + ) => { + const isPopulated = ( + movies.isPopulated && + customFilters.isPopulated && + tags.isPopulated && + qualityProfiles.isPopulated && + uiSettings.isPopulated + ); + + const hasError = !!( + movies.error || + customFilters.error || + tags.error || + qualityProfiles.error || + uiSettings.error + ); + + return { + isPopulated, + hasError, + moviesError: movies.error, + customFiltersError: tags.error, + tagsError: tags.error, + qualityProfilesError: qualityProfiles.error, + uiSettingsError: uiSettings.error, + isSmallScreen: dimensions.isSmallScreen, + isSidebarVisible: app.isSidebarVisible, + enableColorImpairedMode: uiSettings.item.enableColorImpairedMode, + version: app.version, + isUpdated: app.isUpdated, + isDisconnected: app.isDisconnected + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchMovies() { + dispatch(fetchMovies()); + }, + dispatchFetchCustomFilters() { + dispatch(fetchCustomFilters()); + }, + dispatchFetchTags() { + dispatch(fetchTags()); + }, + dispatchFetchQualityProfiles() { + dispatch(fetchQualityProfiles()); + }, + dispatchFetchUISettings() { + dispatch(fetchUISettings()); + }, + dispatchFetchStatus() { + dispatch(fetchStatus()); + }, + onResize(dimensions) { + dispatch(saveDimensions(dimensions)); + }, + onSidebarVisibleChange(isSidebarVisible) { + dispatch(setIsSidebarVisible({ isSidebarVisible })); + } + }; +} + +class PageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isLocalStorageSupported: testLocalStorage() + }; + } + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.dispatchFetchMovies(); + this.props.dispatchFetchCustomFilters(); + this.props.dispatchFetchTags(); + this.props.dispatchFetchQualityProfiles(); + this.props.dispatchFetchUISettings(); + this.props.dispatchFetchStatus(); + } + } + + // + // Listeners + + onSidebarToggle = () => { + this.props.onSidebarVisibleChange(!this.props.isSidebarVisible); + } + + // + // Render + + render() { + const { + isPopulated, + hasError, + dispatchFetchMovies, + dispatchFetchTags, + dispatchFetchQualityProfiles, + dispatchFetchUISettings, + dispatchFetchStatus, + ...otherProps + } = this.props; + + if (hasError || !this.state.isLocalStorageSupported) { + return ( + + ); + } + + if (isPopulated) { + return ( + + ); + } + + return ( + + ); + } +} + +PageConnector.propTypes = { + isPopulated: PropTypes.bool.isRequired, + hasError: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + dispatchFetchMovies: PropTypes.func.isRequired, + dispatchFetchCustomFilters: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchQualityProfiles: PropTypes.func.isRequired, + dispatchFetchUISettings: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default withRouter( + connect(createMapStateToProps, createMapDispatchToProps)(PageConnector) +); diff --git a/frontend/src/Components/Page/PageContent.css b/frontend/src/Components/Page/PageContent.css new file mode 100644 index 000000000..4580077c3 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.css @@ -0,0 +1,8 @@ +.content { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-x: hidden; + width: 100%; +} diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js new file mode 100644 index 000000000..62d5b5d13 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import PageContentError from './PageContentError'; +import styles from './PageContent.css'; + +function PageContent(props) { + const { + className, + title, + children + } = props; + + return ( + + +
+ {children} +
+
+
+ ); +} + +PageContent.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContent.defaultProps = { + className: styles.content +}; + +export default PageContent; diff --git a/frontend/src/Components/Page/PageContentBody.css b/frontend/src/Components/Page/PageContentBody.css new file mode 100644 index 000000000..8b41754dd --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.css @@ -0,0 +1,19 @@ +.contentBody { + /* 1px for flex-basis so the div grows correctly in Edge/Firefox */ + flex: 1 0 1px; +} + +.innerContentBody { + padding: $pageContentBodyPadding; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentBody { + flex-basis: auto; + overflow-y: hidden !important; + } + + .innerContentBody { + padding: $pageContentBodyPaddingSmallScreen; + } +} diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js new file mode 100644 index 000000000..81bd9b29b --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './PageContentBody.css'; + +class PageContentBody extends Component { + + // + // Render + + render() { + const { + className, + innerClassName, + isSmallScreen, + children, + dispatch, + ...otherProps + } = this.props; + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( + +
+ {children} +
+
+ ); + } +} + +PageContentBody.propTypes = { + className: PropTypes.string, + innerClassName: PropTypes.string, + isSmallScreen: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + dispatch: PropTypes.func +}; + +PageContentBody.defaultProps = { + className: styles.contentBody, + innerClassName: styles.innerContentBody +}; + +export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBodyConnector.js b/frontend/src/Components/Page/PageContentBodyConnector.js new file mode 100644 index 000000000..b5cdfbb21 --- /dev/null +++ b/frontend/src/Components/Page/PageContentBodyConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import PageContentBody from './PageContentBody'; + +function createMapStateToProps() { + return createSelector( + createDimensionsSelector(), + (dimensions) => { + return { + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(PageContentBody); diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css new file mode 100644 index 000000000..7b1f7a6db --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.css @@ -0,0 +1,3 @@ +.content { + composes: content from './PageContent.css'; +} diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.js new file mode 100644 index 000000000..5ae41a936 --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import PageContentBodyConnector from './PageContentBodyConnector'; +import styles from './PageContentError.css'; + +function PageContentError(props) { + return ( +
+ + + +
+ ); +} + +export default PageContentError; diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css new file mode 100644 index 000000000..74bdb3811 --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -0,0 +1,26 @@ +.contentFooter { + display: flex; + flex: 0 0 auto; + padding: 20px; + background-color: #f1f1f1; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentFooter { + display: block; + + div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + } +} + +@media only screen and (max-width: $breakpointLarge) { + .contentFooter { + flex-wrap: wrap; + } +} diff --git a/frontend/src/Components/Page/PageContentFooter.js b/frontend/src/Components/Page/PageContentFooter.js new file mode 100644 index 000000000..1f6e2d21a --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './PageContentFooter.css'; + +class PageContentFooter extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +PageContentFooter.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContentFooter.defaultProps = { + className: styles.contentFooter +}; + +export default PageContentFooter; diff --git a/frontend/src/Components/Page/PageJumpBar.css b/frontend/src/Components/Page/PageJumpBar.css new file mode 100644 index 000000000..9a116fb54 --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.css @@ -0,0 +1,22 @@ +.jumpBar { + display: flex; + align-content: stretch; + align-items: stretch; + align-self: stretch; + justify-content: center; + flex: 0 0 30px; +} + +.jumpBarItems { + display: flex; + justify-content: space-around; + flex: 0 0 100%; + flex-direction: column; + overflow: hidden; +} + +@media only screen and (max-width: $breakpointSmall) { + .jumpBar { + display: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js new file mode 100644 index 000000000..f555d5426 --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.js @@ -0,0 +1,140 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import dimensions from 'Styles/Variables/dimensions'; +import Measure from 'Components/Measure'; +import PageJumpBarItem from './PageJumpBarItem'; +import styles from './PageJumpBar.css'; + +const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight); + +class PageJumpBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 0, + visibleItems: props.items + }; + } + + componentDidMount() { + this.computeVisibleItems(); + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + nextProps.items !== this.props.items || + nextState.height !== this.state.height + ); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevProps.items !== this.props.items || + prevState.height !== this.state.height + ) { + this.computeVisibleItems(); + } + } + + // + // Control + + computeVisibleItems() { + const { + items, + minimumItems + } = this.props; + + const height = this.state.height; + const maximumItems = Math.floor(height / ITEM_HEIGHT); + const diff = items.length - maximumItems; + + if (diff < 0) { + this.setState({ visibleItems: items }); + return; + } + + if (items.length < minimumItems) { + this.setState({ visibleItems: items }); + return; + } + + const removeDiff = Math.ceil(items.length / maximumItems); + + const visibleItems = _.reduce(items, (acc, item, index) => { + if (index % removeDiff === 0) { + acc.push(item); + } + + return acc; + }, []); + + this.setState({ visibleItems }); + } + + // + // Listeners + + onMeasure = ({ height }) => { + this.setState({ height }); + } + + // + // Render + + render() { + const { + minimumItems, + onItemPress + } = this.props; + + const { + visibleItems + } = this.state; + + if (!visibleItems.length || visibleItems.length < minimumItems) { + return null; + } + + return ( +
+ +
+ { + visibleItems.map((item) => { + return ( + + ); + }) + } +
+
+
+ ); + } +} + +PageJumpBar.propTypes = { + items: PropTypes.arrayOf(PropTypes.string).isRequired, + minimumItems: PropTypes.number.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +PageJumpBar.defaultProps = { + minimumItems: 5 +}; + +export default PageJumpBar; diff --git a/frontend/src/Components/Page/PageJumpBarItem.css b/frontend/src/Components/Page/PageJumpBarItem.css new file mode 100644 index 000000000..e829dd31a --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.css @@ -0,0 +1,14 @@ +.jumpBarItem { + flex: 1 0 $jumpBarItemHeight; + border-bottom: 1px solid $borderColor; + text-align: center; + font-weight: bold; + + &:hover { + color: #777; + } + + &:last-child { + border: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBarItem.js b/frontend/src/Components/Page/PageJumpBarItem.js new file mode 100644 index 000000000..aeffe4ddd --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './PageJumpBarItem.css'; + +class PageJumpBarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + label, + onItemPress + } = this.props; + + onItemPress(label); + } + + // + // Render + + render() { + return ( + + {this.props.label.toUpperCase()} + + ); + } +} + +PageJumpBarItem.propTypes = { + label: PropTypes.string.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +export default PageJumpBarItem; diff --git a/frontend/src/Components/Page/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js new file mode 100644 index 000000000..774b88669 --- /dev/null +++ b/frontend/src/Components/Page/PageSectionContent.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; + +function PageSectionContent(props) { + const { + isFetching, + isPopulated, + error, + errorMessage, + children + } = props; + + if (isFetching) { + return ( + + ); + } else if (!isFetching && !!error) { + return ( +
{errorMessage}
+ ); + } else if (isPopulated && !error) { + return ( +
{children}
+ ); + } + + return null; +} + +PageSectionContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + errorMessage: PropTypes.string.isRequired, + children: PropTypes.node.isRequired +}; + +export default PageSectionContent; diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.css b/frontend/src/Components/Page/Sidebar/Messages/Message.css new file mode 100644 index 000000000..7d53adb69 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.css @@ -0,0 +1,42 @@ +.message { + display: flex; + border-left: 3px solid $infoColor; +} + +.iconContainer, +.text { + display: flex; + justify-content: center; + flex-direction: column; + padding: 2px 0; + color: $sidebarColor; +} + +.iconContainer { + flex: 0 0 25px; + margin-left: 24px; + padding: 10px 0; +} + +.text { + margin-right: 24px; + font-size: 13px; +} + +/* Types */ + +.error { + border-left-color: $dangerColor; +} + +.info { + border-left-color: $infoColor; +} + +.success { + border-left-color: $successColor; +} + +.warning { + border-left-color: $warningColor; +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.js b/frontend/src/Components/Page/Sidebar/Messages/Message.js new file mode 100644 index 000000000..a28a28019 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import styles from './Message.css'; + +function getIconName(name) { + switch (name) { + case 'ApplicationUpdate': + return icons.RESTART; + case 'Backup': + return icons.BACKUP; + case 'CheckHealth': + return icons.HEALTH; + case 'EpisodeSearch': + return icons.SEARCH; + case 'Housekeeping': + return icons.HOUSEKEEPING; + case 'RefreshMovie': + return icons.REFRESH; + case 'RssSync': + return icons.RSS; + case 'SeasonSearch': + return icons.SEARCH; + case 'MovieSearch': + return icons.SEARCH; + case 'UpdateSceneMapping': + return icons.REFRESH; + default: + return icons.SPINNER; + } +} + +function Message(props) { + const { + name, + message, + type + } = props; + + return ( +
+
+ +
+ +
+ {message} +
+
+ ); +} + +Message.propTypes = { + name: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + type: PropTypes.string.isRequired +}; + +export default Message; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js new file mode 100644 index 000000000..06c545c27 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { hideMessage } from 'Store/Actions/appActions'; +import Message from './Message'; + +const mapDispatchToProps = { + hideMessage +}; + +class MessageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._hideTimeoutId = null; + this.scheduleHideMessage(props.hideAfter); + } + + componentDidUpdate() { + this.scheduleHideMessage(this.props.hideAfter); + } + + // + // Control + + scheduleHideMessage = (hideAfter) => { + if (this._hideTimeoutId) { + clearTimeout(this._hideTimeoutId); + } + + if (hideAfter) { + this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000); + } + } + + hideMessage = () => { + this.props.hideMessage({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MessageConnector.propTypes = { + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + hideAfter: PropTypes.number.isRequired, + hideMessage: PropTypes.func.isRequired +}; + +MessageConnector.defaultProps = { + // Hide messages after 60 seconds if there is no activity + // hideAfter: 60 +}; + +export default connect(undefined, mapDispatchToProps)(MessageConnector); diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.css b/frontend/src/Components/Page/Sidebar/Messages/Messages.css new file mode 100644 index 000000000..ef01ad02c --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.css @@ -0,0 +1,11 @@ +.messages { + margin-top: auto; + margin-bottom: 20px; + padding-top: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .messages { + margin-bottom: 0; + } +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.js b/frontend/src/Components/Page/Sidebar/Messages/Messages.js new file mode 100644 index 000000000..ec8876f6e --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MessageConnector from './MessageConnector'; +import styles from './Messages.css'; + +function Messages({ messages }) { + return ( +
+ { + messages.map((message) => { + return ( + + ); + }) + } +
+ ); +} + +Messages.propTypes = { + messages: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Messages; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js new file mode 100644 index 000000000..5d20d9194 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Messages from './Messages'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.messages.items, + (messages) => { + return { + messages: messages.slice().reverse() + }; + } + ); +} + +export default connect(createMapStateToProps)(Messages); diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.css b/frontend/src/Components/Page/Sidebar/PageSidebar.css new file mode 100644 index 000000000..fdbd80320 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.css @@ -0,0 +1,34 @@ +.sidebarContainer { + flex: 0 0 $sidebarWidth; + overflow: hidden; + width: $sidebarWidth; + background-color: $sidebarBackgroundColor; + transition: transform 300ms ease-in-out; + transform: translateX(0); +} + +.sidebar { + display: flex; + flex-direction: column; + overflow: hidden; + background-color: $sidebarBackgroundColor; + color: $white; +} + +@media only screen and (max-width: $breakpointSmall) { + .sidebarContainer { + position: fixed; + top: 0; + z-index: 2; + height: 100vh; + } + + .sidebar { + position: fixed; + z-index: 2; + overflow-y: auto; + width: 100%; + height: 100%; + } +} + diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js new file mode 100644 index 000000000..8d295be28 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -0,0 +1,513 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import dimensions from 'Styles/Variables/dimensions'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector'; +import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; +import MessagesConnector from './Messages/MessagesConnector'; +import PageSidebarItem from './PageSidebarItem'; +import styles from './PageSidebar.css'; + +const HEADER_HEIGHT = parseInt(dimensions.headerHeight); +const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth); + +const links = [ + { + iconName: icons.SERIES_CONTINUING, + title: 'Movies', + to: '/', + alias: '/movies', + children: [ + { + title: 'Add New', + to: '/add/new' + }, + { + title: 'Import', + to: '/add/import' + }, + { + title: 'Discover', + to: '/add/discover' + }, + { + title: 'Lists', + to: '/add/lists' + } + ] + }, + + { + iconName: icons.CALENDAR, + title: 'Calendar', + to: '/calendar' + }, + + { + iconName: icons.ACTIVITY, + title: 'Activity', + to: '/activity/queue', + children: [ + { + title: 'Queue', + to: '/activity/queue', + statusComponent: QueueStatusConnector + }, + { + title: 'History', + to: '/activity/history' + }, + { + title: 'Blacklist', + to: '/activity/blacklist' + } + ] + }, + + { + iconName: icons.SETTINGS, + title: 'Settings', + to: '/settings', + children: [ + { + title: 'Media Management', + to: '/settings/mediamanagement' + }, + { + title: 'Profiles', + to: '/settings/profiles' + }, + { + title: 'Quality', + to: '/settings/quality' + }, + { + title: 'Indexers', + to: '/settings/indexers' + }, + { + title: 'Download Clients', + to: '/settings/downloadclients' + }, + { + title: 'Lists', + to: '/settings/netimports' + }, + { + title: 'Connect', + to: '/settings/connect' + }, + { + title: 'Metadata', + to: '/settings/metadata' + }, + { + title: 'Tags', + to: '/settings/tags' + }, + { + title: 'General', + to: '/settings/general' + }, + { + title: 'UI', + to: '/settings/ui' + } + ] + }, + + { + iconName: icons.SYSTEM, + title: 'System', + to: '/system/status', + children: [ + { + title: 'Status', + to: '/system/status', + statusComponent: HealthStatusConnector + }, + { + title: 'Tasks', + to: '/system/tasks' + }, + { + title: 'Backup', + to: '/system/backup' + }, + { + title: 'Updates', + to: '/system/updates' + }, + { + title: 'Events', + to: '/system/events' + }, + { + title: 'Log Files', + to: '/system/logs/files' + } + ] + } +]; + +function getActiveParent(pathname) { + let activeParent = links[0].to; + + links.forEach((link) => { + if (link.to && link.to === pathname) { + activeParent = link.to; + + return false; + } + + const children = link.children; + + if (children) { + children.forEach((childLink) => { + if (pathname.startsWith(childLink.to)) { + activeParent = link.to; + + return false; + } + }); + } + + if ( + (link.to !== '/' && pathname.startsWith(link.to)) || + (link.alias && pathname.startsWith(link.alias)) + ) { + activeParent = link.to; + + return false; + } + }); + + return activeParent; +} + +function hasActiveChildLink(link, pathname) { + const children = link.children; + + if (!children || !children.length) { + return false; + } + + return _.some(children, (child) => { + return child.to === pathname; + }); +} + +function getPositioning() { + const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY; + const top = Math.max(HEADER_HEIGHT - windowScroll, 0); + const height = window.innerHeight - top; + + return { + top: `${top}px`, + height: `${height}px` + }; +} + +class PageSidebar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStartX = null; + this._touchStartY = null; + this._sidebarRef = null; + + this.state = { + top: dimensions.headerHeight, + height: `${window.innerHeight - HEADER_HEIGHT}px`, + transition: null, + transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }; + } + + componentDidMount() { + if (this.props.isSmallScreen) { + window.addEventListener('click', this.onWindowClick, { capture: true }); + window.addEventListener('scroll', this.onWindowScroll); + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchmove', this.onTouchMove); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + } + } + + componentDidUpdate(prevProps) { + const { + isSidebarVisible + } = this.props; + + const transform = this.state.transform; + + if (prevProps.isSidebarVisible !== isSidebarVisible) { + this._setSidebarTransform(isSidebarVisible); + } else if (transform === 0 && !isSidebarVisible) { + this.props.onSidebarVisibleChange(true); + } else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) { + this.props.onSidebarVisibleChange(false); + } + } + + componentWillUnmount() { + if (this.props.isSmallScreen) { + window.removeEventListener('click', this.onWindowClick, { capture: true }); + window.removeEventListener('scroll', this.onWindowScroll); + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchmove', this.onTouchMove); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + } + } + + // + // Control + + _setSidebarRef = (ref) => { + this._sidebarRef = ref; + } + + _setSidebarTransform(isSidebarVisible, transition, callback) { + this.setState({ + transition, + transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }, callback); + } + + // + // Listeners + + onWindowClick = (event) => { + const sidebar = ReactDOM.findDOMNode(this._sidebarRef); + const toggleButton = document.getElementById('sidebar-toggle-button'); + + if (!sidebar) { + return; + } + + if ( + !sidebar.contains(event.target) && + !toggleButton.contains(event.target) && + this.props.isSidebarVisible + ) { + event.preventDefault(); + event.stopPropagation(); + this.props.onSidebarVisibleChange(false); + } + } + + onWindowScroll = () => { + this.setState(getPositioning()); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStartX = touches[0].pageX; + const touchStartY = touches[0].pageY; + const isSidebarVisible = this.props.isSidebarVisible; + + if (touches.length !== 1) { + return; + } + + if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) { + return; + } else if (!isSidebarVisible && touchStartX > 40) { + return; + } + + this._touchStartX = touchStartX; + this._touchStartY = touchStartY; + } + + onTouchMove = (event) => { + const touches = event.touches; + const currentTouchX = touches[0].pageX; + // const currentTouchY = touches[0].pageY; + // const isSidebarVisible = this.props.isSidebarVisible; + + if (!this._touchStartX) { + return; + } + + // This is a bit funky when trying to close and you scroll + // vertical too much by mistake, commenting out for now. + // TODO: Evaluate if this should be nuked + + // if (Math.abs(this._touchStartY - currentTouchY) > 40) { + // const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1; + + // this.setState({ + // transition: 'none', + // transform + // }); + + // return; + // } + + if (Math.abs(this._touchStartX - currentTouchX) < 40) { + return; + } + + const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0); + + this.setState({ + transition: 'none', + transform + }); + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStartX) { + return; + } + + if (currentTouch > this._touchStartX && currentTouch > 50) { + this._setSidebarTransform(true, 'none'); + } else if (currentTouch < this._touchStartX && currentTouch < 80) { + this._setSidebarTransform(false, 'transform 50ms ease-in-out'); + } else { + this._setSidebarTransform(this.props.isSidebarVisible); + } + + this._touchStartX = null; + this._touchStartY = null; + } + + onTouchCancel = (event) => { + this._touchStartX = null; + this._touchStartY = null; + } + + onItemPress = () => { + this.props.onSidebarVisibleChange(false); + } + + // + // Render + + render() { + const { + location, + isSmallScreen + } = this.props; + + const { + top, + height, + transition, + transform + } = this.state; + + const urlBase = window.Radarr.urlBase; + const pathname = urlBase ? location.pathname.substr(urlBase.length) || '/' : location.pathname; + const activeParent = getActiveParent(pathname); + + let containerStyle = {}; + let sidebarStyle = {}; + + if (isSmallScreen) { + containerStyle = { + transition, + transform: `translateX(${transform}px)` + }; + + sidebarStyle = { + top, + height + }; + } + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( +
+ +
+ { + links.map((link) => { + const childWithStatusComponent = _.find(link.children, (child) => { + return !!child.statusComponent; + }); + + const childStatusComponent = childWithStatusComponent ? + childWithStatusComponent.statusComponent : + null; + + const isActiveParent = activeParent === link.to; + const hasActiveChild = hasActiveChildLink(link, pathname); + + return ( + + { + link.children && link.to === activeParent && + link.children.map((child) => { + return ( + + ); + }) + } + + ); + }) + } +
+ + +
+
+ ); + } +} + +PageSidebar.propTypes = { + location: locationShape.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default PageSidebar; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css new file mode 100644 index 000000000..dac40927f --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css @@ -0,0 +1,50 @@ +.item { + border-left: 3px solid transparent; + color: $sidebarColor; + transition: border-left 0.3s ease-in-out; +} + +.isActiveItem { + border-left: 3px solid $themeBlue; +} + +.link { + display: block; + padding: 12px 24px; + color: $sidebarColor; + + &:hover, + &:focus { + color: $themeBlue; + text-decoration: none; + } +} + +.childLink { + composes: link; + + padding: 10px 24px; +} + +.isActiveLink { + color: $themeBlue; +} + +.isActiveParentLink { + background-color: $sidebarActiveBackgroundColor; +} + +.iconContainer { + display: inline-block; + margin-right: 7px; + width: 18px; + text-align: center; +} + +.noIcon { + margin-left: 25px; +} + +.status { + float: right; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js new file mode 100644 index 000000000..d494150c0 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './PageSidebarItem.css'; + +class PageSidebarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + isChildItem, + isParentItem, + onPress + } = this.props; + + if (isChildItem || !isParentItem) { + onPress(); + } + } + + // + // Render + + render() { + const { + iconName, + title, + to, + isActive, + isActiveParent, + isChildItem, + statusComponent: StatusComponent, + children + } = this.props; + + return ( +
+ + { + !!iconName && + + + + } + + + {title} + + + { + !!StatusComponent && + + + + } + + + { + children && + map(children, (child) => { + return React.cloneElement(child, { isChildItem: true }); + }) + } +
+ ); + } +} + +PageSidebarItem.propTypes = { + iconName: PropTypes.object, + title: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + isActive: PropTypes.bool, + isActiveParent: PropTypes.bool, + isParentItem: PropTypes.bool.isRequired, + isChildItem: PropTypes.bool.isRequired, + statusComponent: PropTypes.func, + children: PropTypes.node, + onPress: PropTypes.func +}; + +PageSidebarItem.defaultProps = { + isChildItem: false +}; + +export default PageSidebarItem; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css new file mode 100644 index 000000000..4dd0cc678 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css @@ -0,0 +1,3 @@ +.status { + composes: label from 'Components/Label.css'; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js new file mode 100644 index 000000000..c1ea615ed --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function PageSidebarStatus({ count, errors, warnings }) { + if (!count) { + return null; + } + + let kind = kinds.INFO; + + if (errors) { + kind = kinds.DANGER; + } else if (warnings) { + kind = kinds.WARNING; + } + + return ( + + ); +} + +PageSidebarStatus.propTypes = { + count: PropTypes.number, + errors: PropTypes.bool, + warnings: PropTypes.bool +}; + +export default PageSidebarStatus; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.css b/frontend/src/Components/Page/Toolbar/PageToolbar.css new file mode 100644 index 000000000..e040bc884 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbar.css @@ -0,0 +1,16 @@ +.toolbar { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + padding: 0 20px; + height: $toolbarHeight; + background-color: $toolbarBackgroundColor; + color: $toolbarColor; + line-height: 60px; +} + +@media only screen and (max-width: $breakpointSmall) { + .toolbar { + padding: 0 10px; + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.js b/frontend/src/Components/Page/Toolbar/PageToolbar.js new file mode 100644 index 000000000..728f1b0d9 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbar.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './PageToolbar.css'; + +class PageToolbar extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +PageToolbar.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageToolbar.defaultProps = { + className: styles.toolbar +}; + +export default PageToolbar; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css new file mode 100644 index 000000000..11944c1e9 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css @@ -0,0 +1,32 @@ +.toolbarButton { + composes: link from 'Components/Link/Link.css'; + + width: $toolbarButtonWidth; + text-align: center; + + &:hover { + color: $toobarButtonHoverColor; + } + + &.isDisabled { + color: $disabledColor; + } +} + +.isDisabled { + color: $disabledColor; +} + +.labelContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 16px; +} + +.label { + padding: 0 3px; + color: $toolbarLabelColor; + font-size: $extraSmallFontSize; + line-height: calc($extraSmallFontSize + 1px); +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js new file mode 100644 index 000000000..381046bf5 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './PageToolbarButton.css'; + +function PageToolbarButton(props) { + const { + label, + iconName, + spinningName, + isDisabled, + isSpinning, + ...otherProps + } = props; + + return ( + + + +
+
+ {label} +
+
+ + ); +} + +PageToolbarButton.propTypes = { + label: PropTypes.string.isRequired, + iconName: PropTypes.object.isRequired, + spinningName: PropTypes.object, + isSpinning: PropTypes.bool, + isDisabled: PropTypes.bool +}; + +PageToolbarButton.defaultProps = { + spinningName: icons.SPINNER, + isDisabled: false, + isSpinning: false +}; + +export default PageToolbarButton; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css new file mode 100644 index 000000000..638636ffb --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css @@ -0,0 +1,40 @@ +.sectionContainer { + display: flex; + flex: 1 1 10%; + overflow: hidden; +} + +.section { + display: flex; + align-items: center; + flex-grow: 1; +} + +.left { + justify-content: flex-start; +} + +.center { + justify-content: center; +} + +.right { + justify-content: flex-end; +} + +.overflowMenuButton { + composes: menuButton from 'Components/Menu/ToolbarMenuButton.css'; +} + +.overflowMenuItemIcon { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + .overflowMenuButton { + &::after { + margin-left: 0; + content: '\25BE'; + } + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js new file mode 100644 index 000000000..35ee586ec --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -0,0 +1,221 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { forEach } from 'Helpers/elementChildren'; +import { align, icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import Measure from 'Components/Measure'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; +import styles from './PageToolbarSection.css'; + +const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); +const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin); +const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1; +const SEPARATOR_NAME = 'PageToolbarSeparator'; + +function calculateOverflowItems(children, isMeasured, width, collapseButtons) { + let buttonCount = 0; + let separatorCount = 0; + const validChildren = []; + + forEach(children, (child) => { + const name = child.type.name; + + if (name === SEPARATOR_NAME) { + separatorCount++; + } else { + buttonCount++; + } + + validChildren.push(child); + }); + + const buttonsWidth = buttonCount * BUTTON_WIDTH; + const separatorsWidth = separatorCount + SEPARATOR_WIDTH; + const totalWidth = buttonsWidth + separatorsWidth; + + // If the width of buttons and separators is less than + // the available width return all valid children. + + if ( + !isMeasured || + !collapseButtons || + totalWidth < width + ) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1); + const buttons = []; + const overflowItems = []; + let actualButtons = 0; + + // Return all buttons if only one is being pushed to the overflow menu. + if (buttonCount - 1 === maxButtons) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + validChildren.forEach((child, index) => { + if (actualButtons < maxButtons) { + if (child.type.name !== SEPARATOR_NAME) { + buttons.push(child); + actualButtons++; + } + } else if (child.type.name !== SEPARATOR_NAME) { + overflowItems.push(child.props); + } + }); + + return { + buttons, + buttonCount, + overflowItems + }; +} + +class PageToolbarSection extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMeasured: false, + width: 0, + buttons: [], + overflowItems: [] + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ + isMeasured: true, + width + }); + } + + // + // Render + + render() { + const { + children, + alignContent, + collapseButtons + } = this.props; + + const { + isMeasured, + width + } = this.state; + + const { + buttons, + buttonCount, + overflowItems + } = calculateOverflowItems(children, isMeasured, width, collapseButtons); + + return ( + +
+ { + isMeasured ? +
+ { + buttons.map((button) => { + return button; + }) + } + + { + !!overflowItems.length && + + + + + { + overflowItems.map((item) => { + const { + iconName, + spinningName, + label, + isDisabled, + isSpinning, + ...otherProps + } = item; + + return ( + + + {label} + + ); + }) + } + + + } +
: + null + } +
+
+ ); + } + +} + +PageToolbarSection.propTypes = { + children: PropTypes.node, + alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]), + collapseButtons: PropTypes.bool.isRequired +}; + +PageToolbarSection.defaultProps = { + alignContent: align.LEFT, + collapseButtons: true +}; + +export default PageToolbarSection; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css new file mode 100644 index 000000000..968673593 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css @@ -0,0 +1,12 @@ +.separator { + margin: 10px $toolbarSeparatorMargin; + height: 40px; + border-right: 1px solid #e5e5e5; + opacity: 0.35; +} + +@media only screen and (max-width: $breakpointSmall) { + .separator { + margin: 10px 5px; + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js new file mode 100644 index 000000000..754248f99 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; +import styles from './PageToolbarSeparator.css'; + +class PageToolbarSeparator extends Component { + + // + // Render + + render() { + return ( +
+ ); + } + +} + +export default PageToolbarSeparator; diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css new file mode 100644 index 000000000..2f0019043 --- /dev/null +++ b/frontend/src/Components/ProgressBar.css @@ -0,0 +1,93 @@ +.container { + position: relative; + overflow: hidden; + width: 100%; + border-radius: 4px; + background-color: #f5f5f5; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progressBar { + position: relative; + z-index: 1; + float: left; + width: 0; + height: 100%; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); + color: $white; + transition: width 0.6s ease; +} + +.frontTextContainer { + z-index: 1; + color: $white; +} + +.backTextContainer, +.frontTextContainer { + position: absolute; + overflow: hidden; + width: 0; + height: 100%; +} + +.backText, +.frontText { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 12px; + cursor: default; +} + +.primary { + background-color: $primaryColor; +} + +.danger { + background-color: $dangerColor; +} + +.success { + background-color: $successColor; +} + +.purple { + background-color: $purple; +} + +.warning { + background-color: $warningColor; +} + +.info { + background-color: $infoColor; +} + +.small { + height: $progressBarSmallHeight; + + .backText, + .frontText { + height: $progressBarSmallHeight; + } +} + +.medium { + height: $progressBarMediumHeight; + + .backText, + .frontText { + height: $progressBarMediumHeight; + } +} + +.large { + height: $progressBarLargeHeight; + + .backText, + .frontText { + height: $progressBarLargeHeight; + } +} diff --git a/frontend/src/Components/ProgressBar.js b/frontend/src/Components/ProgressBar.js new file mode 100644 index 000000000..4f457d558 --- /dev/null +++ b/frontend/src/Components/ProgressBar.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds, sizes } from 'Helpers/Props'; +import styles from './ProgressBar.css'; + +function ProgressBar(props) { + const { + className, + containerClassName, + title, + progress, + precision, + showText, + text, + kind, + size, + width + } = props; + + const progressPercent = `${progress.toFixed(precision)}%`; + const progressText = text || progressPercent; + const actualWidth = width ? `${width}px` : '100%'; + + return ( +
+ { + showText && !!width && +
+
+
+ {progressText} +
+
+
+ } + +
+ { + showText && +
+
+
+ {progressText} +
+
+
+ } +
+ ); +} + +ProgressBar.propTypes = { + className: PropTypes.string, + containerClassName: PropTypes.string, + title: PropTypes.string, + progress: PropTypes.number.isRequired, + precision: PropTypes.number.isRequired, + showText: PropTypes.bool.isRequired, + text: PropTypes.string, + kind: PropTypes.oneOf(kinds.all).isRequired, + size: PropTypes.oneOf(sizes.all).isRequired, + width: PropTypes.number +}; + +ProgressBar.defaultProps = { + className: styles.progressBar, + containerClassName: styles.container, + precision: 1, + showText: false, + kind: kinds.PRIMARY, + size: sizes.MEDIUM +}; + +export default ProgressBar; diff --git a/frontend/src/Components/Router/Switch.js b/frontend/src/Components/Router/Switch.js new file mode 100644 index 000000000..0c0004a50 --- /dev/null +++ b/frontend/src/Components/Router/Switch.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Switch as RouterSwitch } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import { map } from 'Helpers/elementChildren'; + +class Switch extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + + { + map(children, (child) => { + const { + path: childPath, + addUrlBase = true + } = child.props; + + if (!childPath) { + return child; + } + + const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath; + + return React.cloneElement(child, { path }); + }) + } + + ); + } +} + +Switch.propTypes = { + children: PropTypes.node.isRequired +}; + +export default Switch; diff --git a/frontend/src/Components/Scroller/OverlayScroller.css b/frontend/src/Components/Scroller/OverlayScroller.css new file mode 100644 index 000000000..707a9ac6f --- /dev/null +++ b/frontend/src/Components/Scroller/OverlayScroller.css @@ -0,0 +1,15 @@ +.scroller { + /* Placeholder */ +} + +.thumb { + min-height: 50px; + border: 1px solid transparent; + border-radius: 5px; + background-color: $scrollbarBackgroundColor; + background-clip: padding-box; + + &:hover { + background-color: $scrollbarHoverBackgroundColor; + } +} diff --git a/frontend/src/Components/Scroller/OverlayScroller.js b/frontend/src/Components/Scroller/OverlayScroller.js new file mode 100644 index 000000000..23fe6058a --- /dev/null +++ b/frontend/src/Components/Scroller/OverlayScroller.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Scrollbars } from 'react-custom-scrollbars'; +import { scrollDirections } from 'Helpers/Props'; +import styles from './OverlayScroller.css'; + +class OverlayScroller extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scroller = null; + this._isScrolling = false; + } + + componentDidUpdate(prevProps) { + const { + scrollTop + } = this.props; + + if (!this._isScrolling && scrollTop != null && scrollTop !== prevProps.scrollTop) { + this._scroller.scrollTop(scrollTop); + } + } + + // + // Control + + _setScrollRef = (ref) => { + this._scroller = ref; + } + + _renderThumb = (props) => { + return ( +
+ ); + } + + _renderView = (props) => { + return ( +
+ ); + } + + // + // Listers + + onScrollStart = () => { + this._isScrolling = true; + } + + onScrollStop = () => { + this._isScrolling = false; + } + + onScroll = (event) => { + const { + scrollTop, + scrollLeft + } = event.currentTarget; + + this._isScrolling = true; + const onScroll = this.props.onScroll; + + if (onScroll) { + onScroll({ scrollTop, scrollLeft }); + } + } + + // + // Render + + render() { + const { + autoHide, + autoScroll, + children + } = this.props; + + return ( + + {children} + + ); + } + +} + +OverlayScroller.propTypes = { + className: PropTypes.string, + trackClassName: PropTypes.string, + scrollTop: PropTypes.number, + scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired, + autoHide: PropTypes.bool.isRequired, + autoScroll: PropTypes.bool.isRequired, + children: PropTypes.node, + onScroll: PropTypes.func +}; + +OverlayScroller.defaultProps = { + className: styles.scroller, + trackClassName: styles.thumb, + scrollDirection: scrollDirections.VERTICAL, + autoHide: false, + autoScroll: true +}; + +export default OverlayScroller; diff --git a/frontend/src/Components/Scroller/Scroller.css b/frontend/src/Components/Scroller/Scroller.css new file mode 100644 index 000000000..c8783a8de --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.css @@ -0,0 +1,28 @@ +.scroller { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.none { + overflow-x: hidden; + overflow-y: hidden; +} + +.vertical { + overflow-x: hidden; + overflow-y: scroll; + + &.autoScroll { + overflow-y: auto; + } +} + +.horizontal { + overflow-x: scroll; + overflow-y: hidden; + + &.autoScroll { + overflow-x: auto; + } +} diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js new file mode 100644 index 000000000..701ac0cf4 --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { scrollDirections } from 'Helpers/Props'; +import styles from './Scroller.css'; + +class Scroller extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scroller = null; + } + + componentDidMount() { + const { + scrollTop + } = this.props; + + if (this.props.scrollTop != null) { + this._scroller.scrollTop = scrollTop; + } + } + + // + // Control + + _setScrollerRef = (ref) => { + this._scroller = ref; + } + + // + // Render + + render() { + const { + className, + scrollDirection, + autoScroll, + children, + scrollTop, + onScroll, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +Scroller.propTypes = { + className: PropTypes.string, + scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired, + autoScroll: PropTypes.bool.isRequired, + scrollTop: PropTypes.number, + children: PropTypes.node, + onScroll: PropTypes.func +}; + +Scroller.defaultProps = { + scrollDirection: scrollDirections.VERTICAL, + autoScroll: true +}; + +export default Scroller; diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js new file mode 100644 index 000000000..f0af952f5 --- /dev/null +++ b/frontend/src/Components/SignalRConnector.js @@ -0,0 +1,351 @@ +import $ from 'jquery'; +import 'signalr'; +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { repopulatePage } from 'Utilities/pagePopulator'; +import titleCase from 'Utilities/String/titleCase'; +import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions'; +import { setAppValue, setVersion } from 'Store/Actions/appActions'; +import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions'; + +function getState(status) { + switch (status) { + case 0: + return 'connecting'; + case 1: + return 'connected'; + case 2: + return 'reconnecting'; + case 4: + return 'disconnected'; + default: + throw new Error(`invalid status ${status}`); + } +} + +function isAppDisconnected(disconnectedTime) { + if (!disconnectedTime) { + return false; + } + + return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180; +} + +function getHandlerName(name) { + name = titleCase(name); + name = name.replace('/', ''); + + return `handle${name}`; +} + +function createMapStateToProps() { + return createSelector( + (state) => state.app.isReconnecting, + (state) => state.app.isDisconnected, + (state) => state.queue.paged.isPopulated, + (isReconnecting, isDisconnected, isQueuePopulated) => { + return { + isReconnecting, + isDisconnected, + isQueuePopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands, + dispatchUpdateCommand: updateCommand, + dispatchFinishCommand: finishCommand, + dispatchSetAppValue: setAppValue, + dispatchSetVersion: setVersion, + dispatchUpdate: update, + dispatchUpdateItem: updateItem, + dispatchRemoveItem: removeItem, + dispatchFetchHealth: fetchHealth, + dispatchFetchQueue: fetchQueue, + dispatchFetchQueueDetails: fetchQueueDetails, + dispatchFetchRootFolders: fetchRootFolders, + dispatchFetchTags: fetchTags, + dispatchFetchTagDetails: fetchTagDetails +}; + +class SignalRConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] }; + this.signalRconnection = null; + this.retryInterval = 1; + this.retryTimeoutId = null; + this.disconnectedTime = null; + } + + componentDidMount() { + console.log('Starting signalR'); + + this.signalRconnection = $.connection('/signalr', { apiKey: window.Radarr.apiKey }); + + this.signalRconnection.stateChanged(this.onStateChanged); + this.signalRconnection.received(this.onReceived); + this.signalRconnection.reconnecting(this.onReconnecting); + this.signalRconnection.disconnected(this.onDisconnected); + + this.signalRconnection.start(this.signalRconnectionOptions); + } + + componentWillUnmount() { + if (this.retryTimeoutId) { + this.retryTimeoutId = clearTimeout(this.retryTimeoutId); + } + + this.signalRconnection.stop(); + this.signalRconnection = null; + } + + // + // Control + + retryConnection = () => { + if (isAppDisconnected(this.disconnectedTime)) { + this.setState({ + isDisconnected: true + }); + } + + this.retryTimeoutId = setTimeout(() => { + if (!this.signalRconnection) { + console.error('signalR: Connection was disposed'); + return; + } + + this.signalRconnection.start(this.signalRconnectionOptions); + this.retryInterval = Math.min(this.retryInterval + 1, 10); + }, this.retryInterval * 1000); + } + + handleMessage = (message) => { + const { + name, + body + } = message; + + const handler = this[getHandlerName(name)]; + + if (handler) { + handler(body); + return; + } + + console.error(`signalR: Unable to find handler for ${name}`); + } + + handleCalendar = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'calendar', + updateOnly: true, + ...body.resource + }); + } + } + + handleCommand = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchCommands(); + return; + } + + const resource = body.resource; + const status = resource.status; + + // Both sucessful and failed commands need to be + // completed, otherwise they spin until they timeout. + + if (status === 'completed' || status === 'failed') { + this.props.dispatchFinishCommand(resource); + } else { + this.props.dispatchUpdateCommand(resource); + } + } + + handleMoviefile = (body) => { + const section = 'movieFiles'; + + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + + // Repopulate the page to handle recently imported file + repopulatePage('movieFileUpdated'); + } else if (body.action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + + handleHealth = () => { + this.props.dispatchFetchHealth(); + } + + handleMovie = (body) => { + const action = body.action; + const section = 'movies'; + + if (action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + + handleQueue = () => { + if (this.props.isQueuePopulated) { + this.props.dispatchFetchQueue(); + } + } + + handleQueueDetails = () => { + this.props.dispatchFetchQueueDetails(); + } + + handleQueueStatus = (body) => { + this.props.dispatchUpdate({ section: 'queue.status', data: body.resource }); + } + + handleRootfolder = () => { + this.props.dispatchFetchRootFolders(); + } + + handleVersion = (body) => { + const version = body.Version; + + this.props.dispatchSetVersion({ version }); + } + + handleSystemTask = () => { + // No-op for now, we may want this later + } + + handleTag = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchTags(); + this.props.dispatchFetchTagDetails(); + return; + } + } + + // + // Listeners + + onStateChanged = (change) => { + const state = getState(change.newState); + console.log(`signalR: ${state}`); + + if (state === 'connected') { + // Clear disconnected time + this.disconnectedTime = null; + + const { + dispatchFetchCommands, + dispatchSetAppValue + } = this.props; + + // Repopulate the page (if a repopulator is set) to ensure things + // are in sync after reconnecting. + + if (this.props.isReconnecting || this.props.isDisconnected) { + dispatchFetchCommands(); + repopulatePage(); + } + + dispatchSetAppValue({ + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false + }); + + this.retryInterval = 5; + + if (this.retryTimeoutId) { + clearTimeout(this.retryTimeoutId); + } + } + } + + onReceived = (message) => { + console.debug('signalR: received', message.name, message.body); + + this.handleMessage(message); + } + + onReconnecting = () => { + if (window.Radarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isReconnecting: true + }); + } + + onDisconnected = () => { + if (window.Radarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isConnected: false, + isReconnecting: true, + isDisconnected: isAppDisconnected(this.disconnectedTime) + }); + + this.retryConnection(); + } + + // + // Render + + render() { + return null; + } +} + +SignalRConnector.propTypes = { + isReconnecting: PropTypes.bool.isRequired, + isDisconnected: PropTypes.bool.isRequired, + isQueuePopulated: PropTypes.bool.isRequired, + dispatchFetchCommands: PropTypes.func.isRequired, + dispatchUpdateCommand: PropTypes.func.isRequired, + dispatchFinishCommand: PropTypes.func.isRequired, + dispatchSetAppValue: PropTypes.func.isRequired, + dispatchSetVersion: PropTypes.func.isRequired, + dispatchUpdate: PropTypes.func.isRequired, + dispatchUpdateItem: PropTypes.func.isRequired, + dispatchRemoveItem: PropTypes.func.isRequired, + dispatchFetchHealth: PropTypes.func.isRequired, + dispatchFetchQueue: PropTypes.func.isRequired, + dispatchFetchQueueDetails: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchTagDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector); diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js new file mode 100644 index 000000000..d21674d9e --- /dev/null +++ b/frontend/src/Components/SpinnerIcon.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from './Icon'; + +function SpinnerIcon(props) { + const { + name, + spinningName, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIcon.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIcon.defaultProps = { + spinningName: icons.SPINNER +}; + +export default SpinnerIcon; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css new file mode 100644 index 000000000..7be20ce5d --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.css @@ -0,0 +1,5 @@ +.cell { + composes: cell from './TableRowCell.css'; + + width: 180px; +} diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js new file mode 100644 index 000000000..93004b447 --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import TableRowCell from './TableRowCell'; +import styles from './RelativeDateCell.css'; + +function RelativeDateCell(props) { + const { + className, + date, + includeSeconds, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + component: Component, + dispatch, + ...otherProps + } = props; + + if (!date) { + return ( + + ); + } + + return ( + + {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })} + + ); +} + +RelativeDateCell.propTypes = { + className: PropTypes.string.isRequired, + date: PropTypes.string, + includeSeconds: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + component: PropTypes.func, + dispatch: PropTypes.func +}; + +RelativeDateCell.defaultProps = { + className: styles.cell, + includeSeconds: false, + component: TableRowCell +}; + +export default RelativeDateCell; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js new file mode 100644 index 000000000..ed996abbe --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import RelativeDateCell from './RelativeDateCell'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'longDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps, null)(RelativeDateCell); diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css new file mode 100644 index 000000000..1c3e6fc5a --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.css @@ -0,0 +1,11 @@ +.cell { + padding: 8px; + border-top: 1px solid #eee; + line-height: 1.52857143; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/TableRowCell.js b/frontend/src/Components/Table/Cells/TableRowCell.js new file mode 100644 index 000000000..f66bbf3aa --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './TableRowCell.css'; + +class TableRowCell extends Component { + + // + // Render + + render() { + const { + className, + children, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +TableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +TableRowCell.defaultProps = { + className: styles.cell +}; + +export default TableRowCell; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.css b/frontend/src/Components/Table/Cells/TableRowCellButton.css new file mode 100644 index 000000000..f01e7cba6 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css @@ -0,0 +1,4 @@ +.cell { + composes: cell from './TableRowCell.css'; + composes: link from 'Components/Link/Link.css'; +} diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js new file mode 100644 index 000000000..ff50d3bc9 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; +import TableRowCell from './TableRowCell'; +import styles from './TableRowCellButton.css'; + +function TableRowCellButton({ className, ...otherProps }) { + return ( + + ); +} + +TableRowCellButton.propTypes = { + className: PropTypes.string.isRequired +}; + +TableRowCellButton.defaultProps = { + className: styles.cell +}; + +export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css b/frontend/src/Components/Table/Cells/TableSelectCell.css new file mode 100644 index 000000000..21ab944d7 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.css @@ -0,0 +1,11 @@ +.selectCell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js new file mode 100644 index 000000000..9c10f4444 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import TableRowCell from './TableRowCell'; +import styles from './TableSelectCell.css'; + +class TableSelectCell extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + isSelected, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: isSelected }); + } + + componentWillUnmount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: null }); + } + + // + // Listeners + + onChange = ({ value, shiftKey }, a, b, c, d) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + className, + id, + isSelected, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +TableSelectCell.propTypes = { + className: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +TableSelectCell.defaultProps = { + className: styles.selectCell, + isSelected: false +}; + +export default TableSelectCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css new file mode 100644 index 000000000..e4cffe1c4 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css @@ -0,0 +1,14 @@ +.cell { + @add-mixin truncate; + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + flex-grow: 0; + flex-shrink: 1; + white-space: nowrap; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.js b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js new file mode 100644 index 000000000..42999216f --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableRowCell.css'; + +function VirtualTableRowCell(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +VirtualTableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +VirtualTableRowCell.defaultProps = { + className: styles.cell +}; + +export default VirtualTableRowCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css new file mode 100644 index 000000000..e1016aa8a --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css @@ -0,0 +1,11 @@ +.cell { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 36px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js new file mode 100644 index 000000000..a773aab58 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRowCell from './VirtualTableRowCell'; +import styles from './VirtualTableSelectCell.css'; + +export function virtualTableSelectCellRenderer(cellProps) { + const { + cellKey, + rowData, + columnData, + ...otherProps + } = cellProps; + + return ( + + ); +} + +class VirtualTableSelectCell extends Component { + + // + // Listeners + + onChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + inputClassName, + id, + isSelected, + isDisabled, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +VirtualTableSelectCell.propTypes = { + inputClassName: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +VirtualTableSelectCell.defaultProps = { + inputClassName: styles.input, + isSelected: false +}; + +export default VirtualTableSelectCell; diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css new file mode 100644 index 000000000..46d49826a --- /dev/null +++ b/frontend/src/Components/Table/Table.css @@ -0,0 +1,16 @@ +.tableContainer { + overflow-x: auto; +} + +.table { + max-width: 100%; + width: 100%; + border-collapse: collapse; +} + +@media only screen and (max-width: $breakpointSmall) { + .tableContainer { + overflow-y: hidden; + width: 100%; + } +} diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js new file mode 100644 index 000000000..612d95b8c --- /dev/null +++ b/frontend/src/Components/Table/Table.js @@ -0,0 +1,129 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, scrollDirections } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Scroller from 'Components/Scroller/Scroller'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TableHeader from './TableHeader'; +import TableHeaderCell from './TableHeaderCell'; +import TableSelectAllHeaderCell from './TableSelectAllHeaderCell'; +import styles from './Table.css'; + +const tableHeaderCellProps = [ + 'sortKey', + 'sortDirection' +]; + +function getTableHeaderCellProps(props) { + return _.reduce(tableHeaderCellProps, (result, key) => { + if (props.hasOwnProperty(key)) { + result[key] = props[key]; + } + + return result; + }, {}); +} + +function Table(props) { + const { + className, + selectAll, + columns, + optionsComponent, + pageSize, + canModifyColumns, + children, + onSortPress, + onTableOptionChange, + ...otherProps + } = props; + + return ( + + + + { + selectAll && + + } + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if ( + (name === 'actions' || name === 'details') && + onTableOptionChange + ) { + return ( + + + + + + ); + } + + return ( + + {column.label} + + ); + }) + } + + + {children} +
+
+ ); +} + +Table.propTypes = { + className: PropTypes.string, + selectAll: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + optionsComponent: PropTypes.func, + pageSize: PropTypes.number, + canModifyColumns: PropTypes.bool, + children: PropTypes.node, + onSortPress: PropTypes.func, + onTableOptionChange: PropTypes.func +}; + +Table.defaultProps = { + className: styles.table, + selectAll: false +}; + +export default Table; diff --git a/frontend/src/Components/Table/TableBody.js b/frontend/src/Components/Table/TableBody.js new file mode 100644 index 000000000..5cc60d6f4 --- /dev/null +++ b/frontend/src/Components/Table/TableBody.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableBody extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + {children} + ); + } + +} + +TableBody.propTypes = { + children: PropTypes.node +}; + +export default TableBody; diff --git a/frontend/src/Components/Table/TableHeader.js b/frontend/src/Components/Table/TableHeader.js new file mode 100644 index 000000000..81943e919 --- /dev/null +++ b/frontend/src/Components/Table/TableHeader.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableHeader extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + + + {children} + + + ); + } +} + +TableHeader.propTypes = { + children: PropTypes.node +}; + +export default TableHeader; diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css new file mode 100644 index 000000000..c2c4f58c8 --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.css @@ -0,0 +1,16 @@ +.headerCell { + padding: 8px; + border: none !important; + text-align: left; + font-weight: bold; +} + +.sortIcon { + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .headerCell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js new file mode 100644 index 000000000..e4739e63f --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.js @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './TableHeaderCell.css'; + +class TableHeaderCell extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + fixedSortDirection + } = this.props; + + if (fixedSortDirection) { + this.props.onSortPress(name, fixedSortDirection); + } else { + this.props.onSortPress(name); + } + } + + // + // Render + + render() { + const { + className, + name, + columnLabel, + isSortable, + isVisible, + isModifiable, + sortKey, + sortDirection, + fixedSortDirection, + children, + onSortPress, + ...otherProps + } = this.props; + + const isSorting = isSortable && sortKey === name; + const sortIcon = sortDirection === sortDirections.ASCENDING ? + icons.SORT_ASCENDING : + icons.SORT_DESCENDING; + + return ( + isSortable ? + + {children} + + { + isSorting && + + } + : + + + {children} + + ); + } +} + +TableHeaderCell.propTypes = { + className: PropTypes.string, + name: PropTypes.string.isRequired, + columnLabel: PropTypes.string, + isSortable: PropTypes.bool, + isVisible: PropTypes.bool, + isModifiable: PropTypes.bool, + sortKey: PropTypes.string, + fixedSortDirection: PropTypes.string, + sortDirection: PropTypes.string, + children: PropTypes.node, + onSortPress: PropTypes.func +}; + +TableHeaderCell.defaultProps = { + className: styles.headerCell, + isSortable: false +}; + +export default TableHeaderCell; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css new file mode 100644 index 000000000..204773c3d --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css @@ -0,0 +1,48 @@ +.column { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.label { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.notDragable { + padding: 4px 0; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js new file mode 100644 index 000000000..a986be615 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TableOptionsColumn.css'; + +function TableOptionsColumn(props) { + const { + name, + label, + isVisible, + isModifiable, + isDragging, + connectDragSource, + onVisibleChange + } = props; + + return ( +
+
+ + + { + !!connectDragSource && + connectDragSource( +
+ +
+ ) + } +
+
+ ); +} + +TableOptionsColumn.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + isModifiable: PropTypes.bool.isRequired, + index: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + connectDragSource: PropTypes.func, + onVisibleChange: PropTypes.func.isRequired +}; + +export default TableOptionsColumn; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js new file mode 100644 index 000000000..b1d016529 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { TABLE_COLUMN } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import TableOptionsColumn from './TableOptionsColumn'; +import styles from './TableOptionsColumnDragPreview.css'; + +const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); +const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class TableOptionsColumnDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== TABLE_COLUMN) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + return ( + +
+ +
+
+ ); + } +} + +TableOptionsColumnDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css new file mode 100644 index 000000000..9354a35c0 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css @@ -0,0 +1,18 @@ +.columnDragSource { + padding: 4px 0; +} + +.columnPlaceholder { + width: 100%; + height: 36px; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.columnPlaceholderBefore { + margin-bottom: 8px; +} + +.columnPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js new file mode 100644 index 000000000..80f03e430 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { TABLE_COLUMN } from 'Helpers/dragTypes'; +import TableOptionsColumn from './TableOptionsColumn'; +import styles from './TableOptionsColumnDragSource.css'; + +const columnDragSource = { + beginDrag(column) { + return column; + }, + + endDrag(props, monitor, component) { + props.onColumnDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const columnDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().index; + const hoverIndex = props.index; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex === hoverIndex) { + return; + } + + // When moving up, only trigger if drag position is above 50% and + // when moving down, only trigger if drag position is below 50%. + // If we're moving down the hoverIndex needs to be increased + // by one so it's ordered properly. Otherwise the hoverIndex will work. + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + props.onColumnDragMove(dragIndex, hoverIndex); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class TableOptionsColumnDragSource extends Component { + + // + // Render + + render() { + const { + name, + label, + isVisible, + isModifiable, + index, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + onVisibleChange + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
+ { + isBefore && +
+ } + + + + { + isAfter && +
+ } +
+ ); + } +} + +TableOptionsColumnDragSource.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + isModifiable: PropTypes.bool.isRequired, + index: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onVisibleChange: PropTypes.func.isRequired, + onColumnDragMove: PropTypes.func.isRequired, + onColumnDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + TABLE_COLUMN, + columnDropTarget, + collectDropTarget +)(DragSource( + TABLE_COLUMN, + columnDragSource, + collectDragSource +)(TableOptionsColumnDragSource)); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.css b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css new file mode 100644 index 000000000..35544f32b --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css @@ -0,0 +1,5 @@ +.columns { + margin-top: 10px; + width: 100%; + user-select: none; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js new file mode 100644 index 000000000..351d827ca --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js @@ -0,0 +1,252 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import TableOptionsColumn from './TableOptionsColumn'; +import TableOptionsColumnDragSource from './TableOptionsColumnDragSource'; +import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview'; +import styles from './TableOptionsModal.css'; + +class TableOptionsModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPageSize: !!props.pageSize, + pageSize: props.pageSize, + pageSizeError: null, + dragIndex: null, + dropIndex: null + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.pageSize !== this.state.pageSize) { + this.setState({ pageSize: this.props.pageSize }); + } + } + + // + // Listeners + + onPageSizeChange = ({ value }) => { + let pageSizeError = null; + + if (value < 5) { + pageSizeError = 'Page size must be at least 5'; + } else if (value > 250) { + pageSizeError = 'Page size must not exceed 250'; + } else { + this.props.onTableOptionChange({ pageSize: value }); + } + + this.setState({ + pageSize: value, + pageSizeError + }); + } + + onVisibleChange = ({ name, value }) => { + const columns = _.cloneDeep(this.props.columns); + + const column = _.find(columns, { name }); + column.isVisible = value; + + this.props.onTableOptionChange({ columns }); + } + + onColumnDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onColumnDragEnd = ({ id }, didDrop) => { + const { + dragIndex, + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + const columns = _.cloneDeep(this.props.columns); + const items = columns.splice(dragIndex, 1); + columns.splice(dropIndex, 0, items[0]); + + this.props.onTableOptionChange({ columns }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + const { + isOpen, + columns, + canModifyColumns, + optionsComponent: OptionsComponent, + onTableOptionChange, + onModalClose + } = this.props; + + const { + hasPageSize, + pageSize, + pageSizeError, + dragIndex, + dropIndex + } = this.state; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex < dragIndex; + const isDraggingDown = isDragging && dropIndex > dragIndex; + + return ( + + + + Table Options + + + +
+ { + hasPageSize && + + Page Size + + + + } + + { + !!OptionsComponent && + + } + + { + canModifyColumns && + + Columns + +
+ + +
+ { + columns.map((column, index) => { + const { + name, + label, + columnLabel, + isVisible, + isModifiable + } = column; + + if (isModifiable !== false) { + return ( + + ); + } + + return ( + + ); + }) + } + + +
+
+
+ } + +
+ + + +
+
+ ); + } +} + +TableOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + pageSize: PropTypes.number, + canModifyColumns: PropTypes.bool.isRequired, + optionsComponent: PropTypes.func, + onTableOptionChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +TableOptionsModal.defaultProps = { + canModifyColumns: true +}; + +export default DragDropContext(HTML5Backend)(TableOptionsModal); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js new file mode 100644 index 000000000..ff2b8538b --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import TableOptionsModal from './TableOptionsModal'; + +class TableOptionsModalWrapper extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + columns, + children, + ...otherProps + } = this.props; + + return ( + + { + React.cloneElement(children, { onPress: this.onTableOptionsPress }) + } + + + + ); + } +} + +TableOptionsModalWrapper.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + children: PropTypes.node.isRequired +}; + +export default TableOptionsModalWrapper; diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css new file mode 100644 index 000000000..17d300fb9 --- /dev/null +++ b/frontend/src/Components/Table/TablePager.css @@ -0,0 +1,77 @@ +.pager { + display: flex; + align-items: center; + justify-content: space-between; +} + +.loadingContainer, +.controlsContainer, +.recordsContainer { + flex: 0 1 33%; +} + +.controlsContainer { + display: flex; + justify-content: center; +} + +.recordsContainer { + display: flex; + justify-content: flex-end; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin: 0; + margin-left: 5px; + text-align: left; +} + +.controls { + display: flex; + align-items: center; + text-align: center; +} + +.pageNumber { + line-height: 30px; +} + +.pageLink { + padding: 0; + width: 30px; + height: 30px; + line-height: 30px; +} + +.records { + color: $disabledColor; +} + +.disabledPageButton { + color: $disabledColor; +} + +.pageSelect { + composes: select from 'Components/Form/SelectInput.css'; + + padding: 0 2px; + height: 25px; +} + +@media only screen and (max-width: $breakpointSmall) { + .pager { + flex-wrap: wrap; + } + + .loadingContainer, + .recordsContainer { + flex: 0 1 50%; + } + + .controlsContainer { + flex: 0 1 100%; + order: -1; + } +} diff --git a/frontend/src/Components/Table/TablePager.js b/frontend/src/Components/Table/TablePager.js new file mode 100644 index 000000000..3c7c5a8f1 --- /dev/null +++ b/frontend/src/Components/Table/TablePager.js @@ -0,0 +1,180 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectInput from 'Components/Form/SelectInput'; +import styles from './TablePager.css'; + +class TablePager extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isShowingPageSelect: false + }; + } + + // + // Listeners + + onOpenPageSelectClick = () => { + this.setState({ isShowingPageSelect: true }); + } + + onPageSelect = ({ value: page }) => { + this.setState({ isShowingPageSelect: false }); + this.props.onPageSelect(parseInt(page)); + } + + onPageSelectBlur = () => { + this.setState({ isShowingPageSelect: false }); + } + + // + // Render + + render() { + const { + page, + totalPages, + totalRecords, + isFetching, + onFirstPagePress, + onPreviousPagePress, + onNextPagePress, + onLastPagePress + } = this.props; + + const isShowingPageSelect = this.state.isShowingPageSelect; + const pages = Array.from(new Array(totalPages), (x, i) => { + const pageNumber = i + 1; + + return { + key: pageNumber, + value: pageNumber + }; + }); + + if (!page) { + return null; + } + + const isFirstPage = page === 1; + const isLastPage = page === totalPages; + + return ( +
+
+ { + isFetching && + + } +
+ +
+
+ + + + + + + + +
+ { + !isShowingPageSelect && + + {page} / {totalPages} + + } + + { + isShowingPageSelect && + + } +
+ + + + + + + + +
+
+ +
+
+ Total records: {totalRecords} +
+
+
+ ); + } + +} + +TablePager.propTypes = { + page: PropTypes.number, + totalPages: PropTypes.number, + totalRecords: PropTypes.number, + isFetching: PropTypes.bool, + onFirstPagePress: PropTypes.func.isRequired, + onPreviousPagePress: PropTypes.func.isRequired, + onNextPagePress: PropTypes.func.isRequired, + onLastPagePress: PropTypes.func.isRequired, + onPageSelect: PropTypes.func.isRequired +}; + +export default TablePager; diff --git a/frontend/src/Components/Table/TableRow.css b/frontend/src/Components/Table/TableRow.css new file mode 100644 index 000000000..dcc6ad8cf --- /dev/null +++ b/frontend/src/Components/Table/TableRow.css @@ -0,0 +1,7 @@ +.row { + transition: background-color 500ms; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} diff --git a/frontend/src/Components/Table/TableRow.js b/frontend/src/Components/Table/TableRow.js new file mode 100644 index 000000000..c76083183 --- /dev/null +++ b/frontend/src/Components/Table/TableRow.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './TableRow.css'; + +function TableRow(props) { + const { + className, + children, + overlayContent, + ...otherProps + } = props; + + return ( + + {children} + + ); +} + +TableRow.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node, + overlayContent: PropTypes.bool +}; + +TableRow.defaultProps = { + className: styles.row +}; + +export default TableRow; diff --git a/frontend/src/Components/Table/TableRowButton.css b/frontend/src/Components/Table/TableRowButton.css new file mode 100644 index 000000000..70a2238ca --- /dev/null +++ b/frontend/src/Components/Table/TableRowButton.css @@ -0,0 +1,4 @@ +.row { + composes: link from 'Components/Link/Link.css'; + composes: row from './TableRow.css'; +} diff --git a/frontend/src/Components/Table/TableRowButton.js b/frontend/src/Components/Table/TableRowButton.js new file mode 100644 index 000000000..7ff679673 --- /dev/null +++ b/frontend/src/Components/Table/TableRowButton.js @@ -0,0 +1,16 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import TableRow from './TableRow'; +import styles from './TableRowButton.css'; + +function TableRowButton(props) { + return ( + + ); +} + +export default TableRowButton; diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.css b/frontend/src/Components/Table/TableSelectAllHeaderCell.css new file mode 100644 index 000000000..6090e6e9c --- /dev/null +++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.css @@ -0,0 +1,11 @@ +.selectAllHeaderCell { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + width: 30px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.js b/frontend/src/Components/Table/TableSelectAllHeaderCell.js new file mode 100644 index 000000000..c889c32ae --- /dev/null +++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableHeaderCell from './TableHeaderCell'; +import styles from './TableSelectAllHeaderCell.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +function TableSelectAllHeaderCell(props) { + const { + allSelected, + allUnselected, + onSelectAllChange + } = props; + + const value = getValue(allSelected, allUnselected); + + return ( + + + + ); +} + +TableSelectAllHeaderCell.propTypes = { + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default TableSelectAllHeaderCell; diff --git a/frontend/src/Components/Table/VirtualTable.css b/frontend/src/Components/Table/VirtualTable.css new file mode 100644 index 000000000..3287c5643 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTable.css @@ -0,0 +1,3 @@ +.tableContainer { + width: 100%; +} diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js new file mode 100644 index 000000000..a13807647 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTable.js @@ -0,0 +1,167 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { WindowScroller } from 'react-virtualized'; +import { scrollDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import Scroller from 'Components/Scroller/Scroller'; +import VirtualTableBody from './VirtualTableBody'; +import styles from './VirtualTable.css'; + +const ROW_HEIGHT = 38; + +function overscanIndicesGetter(options) { + const { + cellCount, + overscanCellsCount, + startIndex, + stopIndex + } = options; + + // The default getter takes the scroll direction into account, + // but that can cause issues. Ignore the scroll direction and + // always over return more items. + + const overscanStartIndex = startIndex - overscanCellsCount; + const overscanStopIndex = stopIndex + overscanCellsCount; + + return { + overscanStartIndex: Math.max(0, overscanStartIndex), + overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex) + }; +} + +class VirtualTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0 + }; + + this._isInitialized = false; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps, preState) { + const scrollIndex = this.props.scrollIndex; + + if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) { + const scrollTop = (scrollIndex + 1) * ROW_HEIGHT + 20; + + this.props.onScroll({ scrollTop }); + } + } + + // + // Control + + rowGetter = ({ index }) => { + return this.props.items[index]; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ + width + }); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + className, + items, + isSmallScreen, + header, + headerHeight, + scrollTop, + rowRenderer, + onScroll, + ...otherProps + } = this.props; + + const { + width + } = this.state; + + return ( + + + {({ height, isScrolling }) => { + return ( + + {header} + + + + ); + } + } + + + ); + } +} + +VirtualTable.propTypes = { + className: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + scrollTop: PropTypes.number.isRequired, + scrollIndex: PropTypes.number, + contentBody: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + header: PropTypes.node.isRequired, + headerHeight: PropTypes.number.isRequired, + rowRenderer: PropTypes.func.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +VirtualTable.defaultProps = { + className: styles.tableContainer, + headerHeight: 38, + onRender: () => {} +}; + +export default VirtualTable; diff --git a/frontend/src/Components/Table/VirtualTableBody.css b/frontend/src/Components/Table/VirtualTableBody.css new file mode 100644 index 000000000..12768646d --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableBody.css @@ -0,0 +1,3 @@ +.tableBodyContainer { + position: relative; +} diff --git a/frontend/src/Components/Table/VirtualTableBody.js b/frontend/src/Components/Table/VirtualTableBody.js new file mode 100644 index 000000000..de88bd03c --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableBody.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid } from 'react-virtualized'; +import styles from './VirtualTableBody.css'; + +class VirtualTableBody extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +VirtualTableBody.propTypes = { + className: PropTypes.string.isRequired +}; + +VirtualTableBody.defaultProps = { + className: styles.tableBodyContainer +}; + +export default VirtualTableBody; diff --git a/frontend/src/Components/Table/VirtualTableHeader.css b/frontend/src/Components/Table/VirtualTableHeader.css new file mode 100644 index 000000000..4b757c1f8 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeader.css @@ -0,0 +1,3 @@ +.header { + display: flex; +} diff --git a/frontend/src/Components/Table/VirtualTableHeader.js b/frontend/src/Components/Table/VirtualTableHeader.js new file mode 100644 index 000000000..cf6a0f47b --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeader.js @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableHeader.css'; + +function VirtualTableHeader({ children }) { + return ( +
+ {children} +
+ ); +} + +VirtualTableHeader.propTypes = { + children: PropTypes.node +}; + +export default VirtualTableHeader; diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.css b/frontend/src/Components/Table/VirtualTableHeaderCell.css new file mode 100644 index 000000000..c2c4f58c8 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeaderCell.css @@ -0,0 +1,16 @@ +.headerCell { + padding: 8px; + border: none !important; + text-align: left; + font-weight: bold; +} + +.sortIcon { + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .headerCell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.js b/frontend/src/Components/Table/VirtualTableHeaderCell.js new file mode 100644 index 000000000..bf51062e9 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeaderCell.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './VirtualTableHeaderCell.css'; + +export function headerRenderer(headerProps) { + const { + columnData = {}, + dataKey, + label + } = headerProps; + + return ( + + {label} + + ); +} + +class VirtualTableHeaderCell extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + fixedSortDirection + } = this.props; + + if (fixedSortDirection) { + this.props.onSortPress(name, fixedSortDirection); + } else { + this.props.onSortPress(name); + } + } + + // + // Render + + render() { + const { + className, + name, + isSortable, + sortKey, + sortDirection, + fixedSortDirection, + children, + onSortPress, + ...otherProps + } = this.props; + + const isSorting = isSortable && sortKey === name; + const sortIcon = sortDirection === sortDirections.ASCENDING ? + icons.SORT_ASCENDING : + icons.SORT_DESCENDING; + + return ( + isSortable ? + + {children} + + { + isSorting && + + } + : + +
+ {children} +
+ ); + } +} + +VirtualTableHeaderCell.propTypes = { + className: PropTypes.string, + name: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + isSortable: PropTypes.bool, + sortKey: PropTypes.string, + fixedSortDirection: PropTypes.string, + sortDirection: PropTypes.string, + children: PropTypes.node, + onSortPress: PropTypes.func +}; + +VirtualTableHeaderCell.defaultProps = { + className: styles.headerCell, + isSortable: false +}; + +export default VirtualTableHeaderCell; diff --git a/frontend/src/Components/Table/VirtualTableRow.css b/frontend/src/Components/Table/VirtualTableRow.css new file mode 100644 index 000000000..f4c825b64 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableRow.css @@ -0,0 +1,14 @@ +.row { + display: flex; + transition: background-color 500ms; + + &:hover { + background-color: #fafbfc; + } +} + +@media only screen and (max-width: $breakpointMedium) { + .row { + overflow-x: visible !important; + } +} diff --git a/frontend/src/Components/Table/VirtualTableRow.js b/frontend/src/Components/Table/VirtualTableRow.js new file mode 100644 index 000000000..0a423902e --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableRow.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableRow.css'; + +function VirtualTableRow(props) { + const { + className, + children, + style, + ...otherProps + } = props; + + return ( +
+ {children} +
+ ); +} + +VirtualTableRow.propTypes = { + className: PropTypes.string.isRequired, + style: PropTypes.object.isRequired, + children: PropTypes.node +}; + +VirtualTableRow.defaultProps = { + className: styles.row +}; + +export default VirtualTableRow; diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css new file mode 100644 index 000000000..1f3f7fb30 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css @@ -0,0 +1,11 @@ +.selectAllHeaderCell { + composes: headerCell from 'Components/Table/TableHeaderCell.css'; + + flex: 0 0 36px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js new file mode 100644 index 000000000..58b246763 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableHeaderCell from './VirtualTableHeaderCell'; +import styles from './VirtualTableSelectAllHeaderCell.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +function VirtualTableSelectAllHeaderCell(props) { + const { + allSelected, + allUnselected, + onSelectAllChange + } = props; + + const value = getValue(allSelected, allUnselected); + + return ( + + + + ); +} + +VirtualTableSelectAllHeaderCell.propTypes = { + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default VirtualTableSelectAllHeaderCell; diff --git a/frontend/src/Components/TagList.css b/frontend/src/Components/TagList.css new file mode 100644 index 000000000..c1e5567bd --- /dev/null +++ b/frontend/src/Components/TagList.css @@ -0,0 +1,3 @@ +.tags { + flex: 1 0 auto; +} diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js new file mode 100644 index 000000000..485651bdc --- /dev/null +++ b/frontend/src/Components/TagList.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from './Label'; +import styles from './TagList.css'; + +function TagList({ tags, tagList }) { + return ( +
+ { + tags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + return ( + + ); + }) + } +
+ ); +} + +TagList.propTypes = { + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default TagList; diff --git a/frontend/src/Components/TagListConnector.js b/frontend/src/Components/TagListConnector.js new file mode 100644 index 000000000..be7e618e3 --- /dev/null +++ b/frontend/src/Components/TagListConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import TagList from './TagList'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(TagList); diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css new file mode 100644 index 000000000..f7b87f0b9 --- /dev/null +++ b/frontend/src/Components/Tooltip/Popover.css @@ -0,0 +1,105 @@ +.tether { + z-index: 2000; +} + +.popoverContainer { + margin: 10px 15px; +} + +.popover { + position: relative; + background-color: $white; + box-shadow: 0 5px 10px $popoverShadowColor; +} + +.arrow, +.arrow::after { + position: absolute; + display: block; + width: 0; + height: 0; + border-width: 11px; + border-style: solid; + border-color: transparent; +} + +.arrow::after { + border-width: 10px; + content: ''; +} + +.top { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-top-color: $popoverArrowBorderColor; + border-bottom-width: 0; + + &::after { + bottom: 1px; + margin-left: -10px; + border-top-color: $white; + border-bottom-width: 0; + content: ' '; + } +} + +.right { + top: 50%; + left: -11px; + margin-top: -11px; + border-right-color: $popoverArrowBorderColor; + border-left-width: 0; + + &::after { + bottom: -10px; + left: 1px; + border-right-color: $white; + border-left-width: 0; + content: ' '; + } +} + +.bottom { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: $popoverArrowBorderColor; + + &::after { + top: 1px; + margin-left: -10px; + border-top-width: 0; + border-bottom-color: $white; + content: ' '; + } +} + +.left { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + border-left-color: $popoverArrowBorderColor; + + &::after { + right: 1px; + bottom: -10px; + border-right-width: 0; + border-left-color: $white; + content: ' '; + } +} + +.title { + padding: 10px 20px; + border-bottom: 1px solid $popoverTitleBorderColor; + background-color: $popoverTitleBackgroundColor; + font-size: 16px; +} + +.body { + overflow: auto; + padding: 10px; +} diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js new file mode 100644 index 000000000..c958fce1b --- /dev/null +++ b/frontend/src/Components/Tooltip/Popover.js @@ -0,0 +1,160 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TetherComponent from 'react-tether'; +import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; +import { tooltipPositions } from 'Helpers/Props'; +import styles from './Popover.css'; + +const baseTetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] +}; + +const tetherOptions = { + [tooltipPositions.TOP]: { + ...baseTetherOptions, + attachment: 'bottom center', + targetAttachment: 'top center' + }, + + [tooltipPositions.RIGHT]: { + ...baseTetherOptions, + attachment: 'middle left', + targetAttachment: 'middle right' + }, + + [tooltipPositions.BOTTOM]: { + ...baseTetherOptions, + attachment: 'top center', + targetAttachment: 'bottom center' + }, + + [tooltipPositions.LEFT]: { + ...baseTetherOptions, + attachment: 'middle right', + targetAttachment: 'middle left' + } +}; + +class Popover extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOpen: false + }; + + this._closeTimeout = null; + } + + componentWillUnmount() { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + } + + // + // Listeners + + onClick = () => { + if (isMobileUtil()) { + this.setState({ isOpen: !this.state.isOpen }); + } + } + + onMouseEnter = () => { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + + this.setState({ isOpen: true }); + } + + onMouseLeave = () => { + this._closeTimeout = setTimeout(() => { + this.setState({ isOpen: false }); + }, 100); + } + + // + // Render + + render() { + const { + className, + anchor, + title, + body, + position + } = this.props; + + return ( + + + {anchor} + + + { + this.state.isOpen && +
+
+
+ +
+ {title} +
+ +
+ {body} +
+
+
+ } + + ); + } +} + +Popover.propTypes = { + className: PropTypes.string, + anchor: PropTypes.node.isRequired, + title: PropTypes.string.isRequired, + body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + position: PropTypes.oneOf(tooltipPositions.all) +}; + +Popover.defaultProps = { + position: tooltipPositions.TOP +}; + +export default Popover; diff --git a/frontend/src/Components/Tooltip/Tooltip.css b/frontend/src/Components/Tooltip/Tooltip.css new file mode 100644 index 000000000..d1d798e0f --- /dev/null +++ b/frontend/src/Components/Tooltip/Tooltip.css @@ -0,0 +1,161 @@ +.tether { + z-index: 2000; +} + +.tooltipContainer { + margin: 10px 15px; +} + +.tooltip { + position: relative; + + &.default { + background-color: $white; + box-shadow: 0 5px 10px $popoverShadowColor; + } + + &.inverse { + background-color: $themeDarkColor; + box-shadow: 0 5px 10px $popoverShadowInverseColor; + } +} + +.arrow, +.arrow::after { + position: absolute; + display: block; + width: 0; + height: 0; + border-width: 11px; + border-style: solid; + border-color: transparent; +} + +.arrow::after { + border-width: 10px; + content: ''; +} + +.top { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-bottom-width: 0; + + &::after { + bottom: 1px; + margin-left: -10px; + border-bottom-width: 0; + content: ' '; + + &.default { + border-top-color: $popoverArrowBorderColor; + } + + &.inverse { + border-top-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-top-color: $popoverArrowBorderColor; + } + + &.inverse { + border-top-color: $popoverArrowBorderInverseColor; + } +} + +.right { + top: 50%; + left: -11px; + margin-top: -11px; + border-left-width: 0; + + &::after { + bottom: -10px; + left: 1px; + border-left-width: 0; + content: ' '; + + &.default { + border-right-color: $popoverArrowBorderColor; + } + + &.inverse { + border-right-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-right-color: $popoverArrowBorderColor; + } + + &.inverse { + border-right-color: $popoverArrowBorderInverseColor; + } +} + +.bottom { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + + &::after { + top: 1px; + margin-left: -10px; + border-top-width: 0; + content: ' '; + + &.default { + border-bottom-color: $popoverArrowBorderColor; + } + + &.inverse { + border-bottom-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-bottom-color: $popoverArrowBorderColor; + } + + &.inverse { + border-bottom-color: $popoverArrowBorderInverseColor; + } +} + +.left { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + + &::after { + right: 1px; + bottom: -10px; + border-right-width: 0; + content: ' '; + + &.default { + border-left-color: $popoverArrowBorderColor; + } + + &.inverse { + border-left-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-left-color: $popoverArrowBorderColor; + } + + &.inverse { + border-left-color: $popoverArrowBorderInverseColor; + } +} + +.body { + padding: 5px; +} diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js new file mode 100644 index 000000000..43caf87e8 --- /dev/null +++ b/frontend/src/Components/Tooltip/Tooltip.js @@ -0,0 +1,163 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TetherComponent from 'react-tether'; +import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; +import { kinds, tooltipPositions } from 'Helpers/Props'; +import styles from './Tooltip.css'; + +const baseTetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] +}; + +const tetherOptions = { + [tooltipPositions.TOP]: { + ...baseTetherOptions, + attachment: 'bottom center', + targetAttachment: 'top center' + }, + + [tooltipPositions.RIGHT]: { + ...baseTetherOptions, + attachment: 'middle left', + targetAttachment: 'middle right' + }, + + [tooltipPositions.BOTTOM]: { + ...baseTetherOptions, + attachment: 'top center', + targetAttachment: 'bottom center' + }, + + [tooltipPositions.LEFT]: { + ...baseTetherOptions, + attachment: 'middle right', + targetAttachment: 'middle left' + } +}; + +class Tooltip extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOpen: false + }; + + this._closeTimeout = null; + } + + componentWillUnmount() { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + } + + // + // Listeners + + onClick = () => { + if (isMobileUtil()) { + this.setState({ isOpen: !this.state.isOpen }); + } + } + + onMouseEnter = () => { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + + this.setState({ isOpen: true }); + } + + onMouseLeave = () => { + this._closeTimeout = setTimeout(() => { + this.setState({ isOpen: false }); + }, 100); + } + + // + // Render + + render() { + const { + className, + anchor, + tooltip, + kind, + position + } = this.props; + + return ( + + + {anchor} + + + { + this.state.isOpen && +
+
+
+ +
+ {tooltip} +
+
+
+ } + + ); + } +} + +Tooltip.propTypes = { + className: PropTypes.string, + anchor: PropTypes.node.isRequired, + tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]), + position: PropTypes.oneOf(tooltipPositions.all) +}; + +Tooltip.defaultProps = { + kind: kinds.DEFAULT, + position: tooltipPositions.TOP +}; + +export default Tooltip; diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js new file mode 100644 index 000000000..109ad86e7 --- /dev/null +++ b/frontend/src/Components/keyboardShortcuts.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; +import Mousetrap from 'mousetrap'; +import getDisplayName from 'Helpers/getDisplayName'; + +export const shortcuts = { + OPEN_KEYBOARD_SHORTCUTS_MODAL: { + key: '?', + name: 'Open This Modal' + }, + + MOVIE_SEARCH_INPUT: { + key: 's', + name: 'Focus Search Box' + }, + + SAVE_SETTINGS: { + key: 'mod+s', + name: 'Save Settings' + } +}; + +function keyboardShortcuts(WrappedComponent) { + class KeyboardShortcuts extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + this._mousetrapBindings = {}; + this._mousetrap = new Mousetrap(); + this._mousetrap.stopCallback = this.stopCallback; + } + + componentWillUnmount() { + this.unbindAllShortcuts(); + this._mousetrap = null; + } + + // + // Control + + bindShortcut = (key, callback, options = {}) => { + this._mousetrap.bind(key, callback); + this._mousetrapBindings[key] = options; + } + + unbindShortcut = (key) => { + delete this._mousetrapBindings[key]; + this._mousetrap.unbind(key); + } + + unbindAllShortcuts = () => { + const keys = Object.keys(this._mousetrapBindings); + + if (!keys.length) { + return; + } + + keys.forEach((binding) => { + this._mousetrap.unbind(binding); + }); + + this._mousetrapBindings = {}; + } + + stopCallback = (event, element, combo) => { + const binding = this._mousetrapBindings[combo]; + + if (!binding || binding.isGlobal) { + return false; + } + + return ( + element.tagName === 'INPUT' || + element.tagName === 'SELECT' || + element.tagName === 'TEXTAREA' || + (element.contentEditable && element.contentEditable === 'true') + ); + } + + // + // Render + + render() { + return ( + + ); + } + } + + KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`; + KeyboardShortcuts.WrappedComponent = WrappedComponent; + + return KeyboardShortcuts; +} + +export default keyboardShortcuts; diff --git a/frontend/src/Components/withCurrentPage.js b/frontend/src/Components/withCurrentPage.js new file mode 100644 index 000000000..5e6d9ccf4 --- /dev/null +++ b/frontend/src/Components/withCurrentPage.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +function withCurrentPage(WrappedComponent) { + function CurrentPage(props) { + const { + history + } = props; + + return ( + + ); + } + + CurrentPage.propTypes = { + history: PropTypes.object.isRequired + }; + + return CurrentPage; +} + +export default withCurrentPage; diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js new file mode 100644 index 000000000..110da9ab2 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import scrollPositions from 'Store/scrollPositions'; + +function withScrollPosition(WrappedComponent, scrollPositionKey) { + function ScrollPosition(props) { + const { + history + } = props; + + const scrollTop = history.action === 'POP' ? + scrollPositions[scrollPositionKey] : + 0; + + return ( + + ); + } + + ScrollPosition.propTypes = { + history: PropTypes.object.isRequired + }; + + return ScrollPosition; +} + +export default withScrollPosition; diff --git a/frontend/src/Content/Fonts/Roboto-Light.ttf b/frontend/src/Content/Fonts/Roboto-Light.ttf new file mode 100644 index 000000000..94c6bcc67 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.ttf differ diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff b/frontend/src/Content/Fonts/Roboto-Light.woff new file mode 100644 index 000000000..ec6bf5749 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff differ diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff2 b/frontend/src/Content/Fonts/Roboto-Light.woff2 new file mode 100644 index 000000000..288201788 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff2 differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.ttf b/frontend/src/Content/Fonts/Roboto-Regular.ttf new file mode 100644 index 000000000..8c082c8de Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.ttf differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff b/frontend/src/Content/Fonts/Roboto-Regular.woff new file mode 100644 index 000000000..464d20623 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff2 b/frontend/src/Content/Fonts/Roboto-Regular.woff2 new file mode 100644 index 000000000..f96619675 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff2 differ diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot new file mode 100644 index 000000000..7a03fb512 Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot differ diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf new file mode 100644 index 000000000..fdd309d71 Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf differ diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff new file mode 100644 index 000000000..0289699c0 Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff differ diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css new file mode 100644 index 000000000..3db8ae6b0 --- /dev/null +++ b/frontend/src/Content/Fonts/fonts.css @@ -0,0 +1,38 @@ +@font-face { + font-weight: 300; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Light.woff2?v=1.1.0') format('woff2'), url('Roboto-Light.woff?v=1.2.0') format('woff'), url('Roboto-Light.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.2.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Ubuntu Mono'; + src: url('UbuntuMono-Regular.eot?#iefix') format('embedded-opentype'), url('UbuntuMono-Regular.woff') format('woff'), url('UbuntuMono-Regular.ttf') format('truetype'); +} + +/* + * text-security-disc + */ + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'text-security-disc'; + src: url('text-security-disc.woff') format('woff'), url('text-security-disc.ttf') format('truetype'); +} diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf new file mode 100644 index 000000000..86038dba8 Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.ttf differ diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff new file mode 100644 index 000000000..bc4cc324b Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.woff differ diff --git a/frontend/src/Content/Images/404.png b/frontend/src/Content/Images/404.png new file mode 100644 index 000000000..deeb83f8f Binary files /dev/null and b/frontend/src/Content/Images/404.png differ diff --git a/frontend/src/Content/Images/Icons/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/android-chrome-192x192.png new file mode 100644 index 000000000..242032ea1 Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/android-chrome-512x512.png new file mode 100644 index 000000000..a9a9e924d Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/apple-touch-icon.png b/frontend/src/Content/Images/Icons/apple-touch-icon.png new file mode 100644 index 000000000..1a3612672 Binary files /dev/null and b/frontend/src/Content/Images/Icons/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/browserconfig.xml b/frontend/src/Content/Images/Icons/browserconfig.xml new file mode 100644 index 000000000..993924968 --- /dev/null +++ b/frontend/src/Content/Images/Icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #00ccff + + + diff --git a/frontend/src/Content/Images/Icons/favicon-16x16.png b/frontend/src/Content/Images/Icons/favicon-16x16.png new file mode 100644 index 000000000..40d6cc24c Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-32x32.png b/frontend/src/Content/Images/Icons/favicon-32x32.png new file mode 100644 index 000000000..a05042842 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico new file mode 100644 index 000000000..73ba8d1ba Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json new file mode 100644 index 000000000..d14732f60 --- /dev/null +++ b/frontend/src/Content/Images/Icons/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "", + "icons": [ + { + "src": "/Content/Images/Icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/Content/Images/Icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#3a3f51", + "background_color": "#3a3f51", + "display": "standalone" +} \ No newline at end of file diff --git a/frontend/src/Content/Images/Icons/mstile-144x144.png b/frontend/src/Content/Images/Icons/mstile-144x144.png new file mode 100644 index 000000000..51b02be34 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-150x150.png b/frontend/src/Content/Images/Icons/mstile-150x150.png new file mode 100644 index 000000000..261955fcb Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-310x150.png b/frontend/src/Content/Images/Icons/mstile-310x150.png new file mode 100644 index 000000000..6d8e7afe3 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-310x310.png b/frontend/src/Content/Images/Icons/mstile-310x310.png new file mode 100644 index 000000000..2fe09d2d4 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-70x70.png b/frontend/src/Content/Images/Icons/mstile-70x70.png new file mode 100644 index 000000000..612b0d293 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/safari-pinned-tab.svg b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg new file mode 100644 index 000000000..2356a9a27 --- /dev/null +++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg @@ -0,0 +1,29 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png new file mode 100644 index 000000000..9b1ae7746 Binary files /dev/null and b/frontend/src/Content/Images/error.png differ diff --git a/frontend/src/Content/Images/logo-full.png b/frontend/src/Content/Images/logo-full.png new file mode 100644 index 000000000..ce2a8c3a1 Binary files /dev/null and b/frontend/src/Content/Images/logo-full.png differ diff --git a/frontend/src/Content/Images/logo.png b/frontend/src/Content/Images/logo.png new file mode 100644 index 000000000..ec87857a8 Binary files /dev/null and b/frontend/src/Content/Images/logo.png differ diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg new file mode 100644 index 000000000..b0a721893 --- /dev/null +++ b/frontend/src/Content/Images/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png new file mode 100644 index 000000000..6a88711c3 Binary files /dev/null and b/frontend/src/Content/Images/poster-dark.png differ diff --git a/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js new file mode 100644 index 000000000..11cca7d1b --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +function createRouteMatchShape(props) { + return PropTypes.shape({ + params: PropTypes.shape({ + ...props + }).isRequired + }); +} + +export default createRouteMatchShape; diff --git a/frontend/src/Helpers/Props/Shapes/locationShape.js b/frontend/src/Helpers/Props/Shapes/locationShape.js new file mode 100644 index 000000000..80b53eb44 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/locationShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +const locationShape = PropTypes.shape({ + pathname: PropTypes.string.isRequired, + search: PropTypes.string.isRequired, + state: PropTypes.object, + action: PropTypes.string, + key: PropTypes.string +}); + +export default locationShape; diff --git a/frontend/src/Helpers/Props/Shapes/settingShape.js b/frontend/src/Helpers/Props/Shapes/settingShape.js new file mode 100644 index 000000000..cd672de27 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/settingShape.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; + +const settingShape = { + value: PropTypes.oneOf([PropTypes.bool, PropTypes.number, PropTypes.string]), + warnings: PropTypes.arrayOf(PropTypes.string).isRequired, + errors: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export const arraySettingShape = { + ...settingShape, + value: PropTypes.array.isRequired +}; + +export const boolSettingShape = { + ...settingShape, + value: PropTypes.bool.isRequired +}; + +export const numberSettingShape = { + ...settingShape, + value: PropTypes.number.isRequired +}; + +export const stringSettingShape = { + ...settingShape, + value: PropTypes.string +}; + +export const tagSettingShape = { + ...settingShape, + value: PropTypes.arrayOf(PropTypes.number).isRequired +}; + +export default settingShape; diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.js new file mode 100644 index 000000000..f381959c6 --- /dev/null +++ b/frontend/src/Helpers/Props/align.js @@ -0,0 +1,5 @@ +export const LEFT = 'left'; +export const CENTER = 'center'; +export const RIGHT = 'right'; + +export const all = [LEFT, CENTER, RIGHT]; diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js new file mode 100644 index 000000000..72722ab63 --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -0,0 +1,50 @@ +import * as filterTypes from './filterTypes'; + +export const ARRAY = 'array'; +export const DATE = 'date'; +export const EXACT = 'exact'; +export const NUMBER = 'number'; +export const STRING = 'string'; + +export const all = [ + ARRAY, + DATE, + EXACT, + NUMBER, + STRING +]; + +export const possibleFilterTypes = { + [ARRAY]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' } + ], + + [DATE]: [ + { key: filterTypes.LESS_THAN, value: 'is before' }, + { key: filterTypes.GREATER_THAN, value: 'is after' }, + { key: filterTypes.IN_LAST, value: 'in the last' }, + { key: filterTypes.IN_NEXT, value: 'in the next' } + ], + + [EXACT]: [ + { key: filterTypes.EQUAL, value: 'is' }, + { key: filterTypes.NOT_EQUAL, value: 'is not' } + ], + + [NUMBER]: [ + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.GREATER_THAN, value: 'greater than' }, + { key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' }, + { key: filterTypes.LESS_THAN, value: 'less than' }, + { key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ], + + [STRING]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }, + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ] +}; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js new file mode 100644 index 000000000..b2e9cf1fc --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -0,0 +1,10 @@ +export const BOOL = 'bool'; +export const BYTES = 'bytes'; +export const DATE = 'date'; +export const DEFAULT = 'default'; +export const INDEXER = 'indexer'; +export const PROTOCOL = 'protocol'; +export const QUALITY = 'quality'; +export const QUALITY_PROFILE = 'qualityProfile'; +export const SERIES_STATUS = 'seriesStatus'; +export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js new file mode 100644 index 000000000..a3ea11956 --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypePredicates.js @@ -0,0 +1,45 @@ +import * as filterTypes from './filterTypes'; + +const filterTypePredicates = { + [filterTypes.CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return itemValue.some((v) => v === filterValue); + } + + return itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.EQUAL]: function(itemValue, filterValue) { + return itemValue === filterValue; + }, + + [filterTypes.GREATER_THAN]: function(itemValue, filterValue) { + return itemValue > filterValue; + }, + + [filterTypes.GREATER_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue >= filterValue; + }, + + [filterTypes.LESS_THAN]: function(itemValue, filterValue) { + return itemValue < filterValue; + }, + + [filterTypes.LESS_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue <= filterValue; + }, + + [filterTypes.NOT_CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return !itemValue.some((v) => v === filterValue); + } + + return !itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.NOT_EQUAL]: function(itemValue, filterValue) { + return itemValue !== filterValue; + } +}; + +export default filterTypePredicates; diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js new file mode 100644 index 000000000..77809b8ce --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypes.js @@ -0,0 +1,21 @@ +export const CONTAINS = 'contains'; +export const EQUAL = 'equal'; +export const GREATER_THAN = 'greaterThan'; +export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual'; +export const IN_LAST = 'inLast'; +export const IN_NEXT = 'inNext'; +export const LESS_THAN = 'lessThan'; +export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual'; +export const NOT_CONTAINS = 'notContains'; +export const NOT_EQUAL = 'notEqual'; + +export const all = [ + CONTAINS, + EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + LESS_THAN, + LESS_THAN_OR_EQUAL, + NOT_CONTAINS, + NOT_EQUAL +]; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js new file mode 100644 index 000000000..1125f661b --- /dev/null +++ b/frontend/src/Helpers/Props/icons.js @@ -0,0 +1,205 @@ +// +// Regular + +import { + faBookmark as farBookmark, + faCalendar as farCalendar, + faCircle as farCircle, + faClock as farClock, + faClone as farClone, + faDotCircle as farDotCircle, + faFile as farFile, + faFileArchive as farFileArchive, + faFileVideo as farFileVideo, + faFolder as farFolder, + faObjectGroup as farObjectGroup, + faHdd as farHdd, + faKeyboard as farKeyboard, + faObjectUngroup as farObjectUngroup +} from '@fortawesome/free-regular-svg-icons'; + +// +// Solid + +import { + faArrowCircleLeft as fasArrowCircleLeft, + faArrowCircleRight as fasArrowCircleRight, + faBackward as fasBackward, + faBars as fasBars, + faBolt as fasBolt, + faBookmark as fasBookmark, + faBookReader as fasBookReader, + faBug as fasBug, + faBroadcastTower as fasBroadcastTower, + faCalendarAlt as fasCalendarAlt, + faCaretDown as fasCaretDown, + faCheck as fasCheck, + faChevronCircleDown as fasChevronCircleDown, + faChevronCircleRight as fasChevronCircleRight, + faChevronCircleUp as fasChevronCircleUp, + faCheckCircle as fasCheckCircle, + faCircle as fasCircle, + faCloudDownloadAlt as fasCloudDownloadAlt, + faCloud as fasCloud, + faCog as fasCog, + faCogs as fasCogs, + faCopy as fasCopy, + faDesktop as fasDesktop, + faDownload as fasDownload, + faEllipsisH as fasEllipsisH, + faExclamationCircle as fasExclamationCircle, + faExclamationTriangle as fasExclamationTriangle, + faExternalLinkAlt as fasExternalLinkAlt, + faEye as fasEye, + faFastBackward as fasFastBackward, + faFastForward as fasFastForward, + faFileInvoice as farFileInvoice, + faFilm as fasFilm, + faFilter as fasFilter, + faFolderOpen as fasFolderOpen, + faForward as fasForward, + faHeart as fasHeart, + faHistory as fasHistory, + faHome as fasHome, + faInfoCircle as fasInfoCircle, + faLaptop as fasLaptop, + faLevelUpAlt as fasLevelUpAlt, + faMedkit as fasMedkit, + faMinus as fasMinus, + faPause as fasPause, + faPlay as fasPlay, + faPlus as fasPlus, + faPowerOff as fasPowerOff, + faQuestion as fasQuestion, + faQuestionCircle as fasQuestionCircle, + faRedoAlt as fasRedoAlt, + faRetweet as fasRetweet, + faRss as fasRss, + faRocket as fasRocket, + faSave as fasSave, + faSearch as fasSearch, + faSignOutAlt as fasSignOutAlt, + faSitemap as fasSitemap, + faSpinner as fasSpinner, + faSort as fasSort, + faSortDown as fasSortDown, + faSortUp as fasSortUp, + faStop as fasStop, + faSync as fasSync, + faTags as fasTags, + faTable as fasTable, + faTh as fasTh, + faThList as fasThList, + faTrashAlt as fasTrashAlt, + faTimes as fasTimes, + faTimesCircle as fasTimesCircle, + faUser as fasUser, + faUserPlus as fasUserPlus, + faVial as fasVial, + faWrench as fasWrench +} from '@fortawesome/free-solid-svg-icons'; + +// +// Icons + +export const ACTIONS = fasBolt; +export const ACTIVITY = farClock; +export const ADD = fasPlus; +export const ALTERNATE_TITLES = farClone; +export const ADVANCED_SETTINGS = fasCog; +export const ARROW_LEFT = fasArrowCircleLeft; +export const ARROW_RIGHT = fasArrowCircleRight; +export const BACKUP = farFileArchive; +export const BUG = fasBug; +export const CALENDAR = fasCalendarAlt; +export const CALENDAR_O = farCalendar; +export const CARET_DOWN = fasCaretDown; +export const CHECK = fasCheck; +export const CHECK_INDETERMINATE = fasMinus; +export const CHECK_CIRCLE = fasCheckCircle; +export const CIRCLE = fasCircle; +export const CIRCLE_OUTLINE = farCircle; +export const CLEAR = fasTrashAlt; +export const CLIPBOARD = fasCopy; +export const CLOSE = fasTimes; +export const CLONE = farClone; +export const COLLAPSE = fasChevronCircleUp; +export const COMPUTER = fasDesktop; +export const DANGER = fasExclamationCircle; +export const DELETE = fasTrashAlt; +export const DOWNLOAD = fasDownload; +export const DOWNLOADED = fasDownload; +export const DOWNLOADING = fasCloudDownloadAlt; +export const DRIVE = farHdd; +export const EDIT = fasWrench; +export const MOVIE_FILE = farFileVideo; +export const EXPAND = fasChevronCircleDown; +export const EXPAND_INDETERMINATE = fasChevronCircleRight; +export const EXTERNAL_LINK = fasExternalLinkAlt; +export const FATAL = fasTimesCircle; +export const FILE = farFile; +export const FILTER = fasFilter; +export const FOLDER = farFolder; +export const FOLDER_OPEN = fasFolderOpen; +export const GROUP = farObjectGroup; +export const HEALTH = fasMedkit; +export const HEART = fasHeart; +export const HISTORY = fasHistory; +export const HOUSEKEEPING = fasHome; +export const INFO = fasInfoCircle; +export const INTERACTIVE = fasUser; +export const KEYBOARD = farKeyboard; +export const LOGOUT = fasSignOutAlt; +export const MEDIA_INFO = farFileInvoice; +export const MISSING = fasExclamationTriangle; +export const MONITORED = fasBookmark; +export const NETWORK = fasBroadcastTower; +export const NAVBAR_COLLAPSE = fasBars; +export const NOT_AIRED = farClock; +export const ORGANIZE = fasSitemap; +export const OVERFLOW = fasEllipsisH; +export const OVERVIEW = fasThList; +export const PAGE_FIRST = fasFastBackward; +export const PAGE_PREVIOUS = fasBackward; +export const PAGE_NEXT = fasForward; +export const PAGE_LAST = fasFastForward; +export const PARENT = fasLevelUpAlt; +export const PAUSED = fasPause; +export const PENDING = farClock; +export const PROFILE = fasUser; +export const POSTER = fasTh; +export const QUEUED = fasCloud; +export const QUICK = fasRocket; +export const REFRESH = fasSync; +export const REMOVE = fasTimes; +export const RESTART = fasRedoAlt; +export const RESTORE = fasHistory; +export const REORDER = fasBars; +export const RSS = fasRss; +export const SAVE = fasSave; +export const SCHEDULED = farClock; +export const SCORE = fasUserPlus; +export const SEARCH = fasSearch; +export const SERIES_CONTINUING = fasPlay; +export const SERIES_ENDED = fasStop; +export const SETTINGS = fasCogs; +export const SHUTDOWN = fasPowerOff; +export const SORT = fasSort; +export const SORT_ASCENDING = fasSortUp; +export const SORT_DESCENDING = fasSortDown; +export const SPINNER = fasSpinner; +export const STUDIO = fasFilm; +export const SUBTRACT = fasMinus; +export const SYSTEM = fasLaptop; +export const TABLE = fasTable; +export const TAGS = fasTags; +export const TBA = fasQuestionCircle; +export const TEST = fasVial; +export const UNGROUP = farObjectUngroup; +export const UNKNOWN = fasQuestion; +export const UNMONITORED = farBookmark; +export const UPDATE = fasRetweet; +export const UNSAVED_SETTING = farDotCircle; +export const VIEW = fasEye; +export const WARNING = fasExclamationTriangle; +export const WIKI = fasBookReader; diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js new file mode 100644 index 000000000..3f4f94f6f --- /dev/null +++ b/frontend/src/Helpers/Props/index.js @@ -0,0 +1,29 @@ +import * as align from './align'; +import * as inputTypes from './inputTypes'; +import * as filterBuilderTypes from './filterBuilderTypes'; +import * as filterBuilderValueTypes from './filterBuilderValueTypes'; +import filterTypePredicates from './filterTypePredicates'; +import * as filterTypes from './filterTypes'; +import * as icons from './icons'; +import * as kinds from './kinds'; +import * as messageTypes from './messageTypes'; +import * as sizes from './sizes'; +import * as scrollDirections from './scrollDirections'; +import * as sortDirections from './sortDirections'; +import * as tooltipPositions from './tooltipPositions'; + +export { + align, + inputTypes, + filterBuilderTypes, + filterBuilderValueTypes, + filterTypePredicates, + filterTypes, + icons, + kinds, + messageTypes, + sizes, + scrollDirections, + sortDirections, + tooltipPositions +}; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js new file mode 100644 index 000000000..92a7d2d39 --- /dev/null +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -0,0 +1,33 @@ +export const AUTO_COMPLETE = 'autoComplete'; +export const CAPTCHA = 'captcha'; +export const CHECK = 'check'; +export const DEVICE = 'device'; +export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect'; +export const NUMBER = 'number'; +export const OAUTH = 'oauth'; +export const PASSWORD = 'password'; +export const PATH = 'path'; +export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; +export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; +export const SELECT = 'select'; +export const TAG = 'tag'; +export const TEXT = 'text'; +export const TEXT_TAG = 'textTag'; + +export const all = [ + AUTO_COMPLETE, + CAPTCHA, + CHECK, + DEVICE, + MOVIE_MONITORED_SELECT, + NUMBER, + OAUTH, + PASSWORD, + PATH, + QUALITY_PROFILE_SELECT, + ROOT_FOLDER_SELECT, + SELECT, + TAG, + TEXT, + TEXT_TAG +]; diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js new file mode 100644 index 000000000..fd2c17f7b --- /dev/null +++ b/frontend/src/Helpers/Props/kinds.js @@ -0,0 +1,23 @@ +export const DANGER = 'danger'; +export const DEFAULT = 'default'; +export const DISABLED = 'disabled'; +export const INFO = 'info'; +export const INVERSE = 'inverse'; +export const PINK = 'pink'; +export const PRIMARY = 'primary'; +export const PURPLE = 'purple'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + DANGER, + DEFAULT, + DISABLED, + INFO, + INVERSE, + PINK, + PRIMARY, + PURPLE, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js new file mode 100644 index 000000000..997354f9d --- /dev/null +++ b/frontend/src/Helpers/Props/messageTypes.js @@ -0,0 +1,11 @@ +export const ERROR = 'error'; +export const INFO = 'info'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + ERROR, + INFO, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js new file mode 100644 index 000000000..5e4a4fe08 --- /dev/null +++ b/frontend/src/Helpers/Props/scrollDirections.js @@ -0,0 +1,5 @@ +export const NONE = 'none'; +export const HORIZONTAL = 'horizontal'; +export const VERTICAL = 'vertical'; + +export const all = [NONE, HORIZONTAL, VERTICAL]; diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js new file mode 100644 index 000000000..d7f85df5e --- /dev/null +++ b/frontend/src/Helpers/Props/sizes.js @@ -0,0 +1,7 @@ +export const EXTRA_SMALL = 'extraSmall'; +export const SMALL = 'small'; +export const MEDIUM = 'medium'; +export const LARGE = 'large'; +export const EXTRA_LARGE = 'extraLarge'; + +export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE]; diff --git a/frontend/src/Helpers/Props/sortDirections.js b/frontend/src/Helpers/Props/sortDirections.js new file mode 100644 index 000000000..ff3b17bb6 --- /dev/null +++ b/frontend/src/Helpers/Props/sortDirections.js @@ -0,0 +1,4 @@ +export const ASCENDING = 'ascending'; +export const DESCENDING = 'descending'; + +export const all = [ASCENDING, DESCENDING]; diff --git a/frontend/src/Helpers/Props/tooltipPositions.js b/frontend/src/Helpers/Props/tooltipPositions.js new file mode 100644 index 000000000..bca3c4ed4 --- /dev/null +++ b/frontend/src/Helpers/Props/tooltipPositions.js @@ -0,0 +1,11 @@ +export const TOP = 'top'; +export const RIGHT = 'right'; +export const BOTTOM = 'bottom'; +export const LEFT = 'left'; + +export const all = [ + TOP, + RIGHT, + BOTTOM, + LEFT +]; diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js new file mode 100644 index 000000000..ed6ba080d --- /dev/null +++ b/frontend/src/Helpers/dragTypes.js @@ -0,0 +1,3 @@ +export const QUALITY_PROFILE_ITEM = 'qualityProfileItem'; +export const DELAY_PROFILE = 'delayProfile'; +export const TABLE_COLUMN = 'tableColumn'; diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js new file mode 100644 index 000000000..1c10b2f0e --- /dev/null +++ b/frontend/src/Helpers/elementChildren.js @@ -0,0 +1,149 @@ +// https://github.com/react-bootstrap/react-element-children + +import React from 'react'; + +/** + * Iterates through children that are typically specified as `props.children`, + * but only maps over children that are "valid components". + * + * The mapFunction provided index will be normalised to the components mapped, + * so an invalid component would not increase the index. + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @return {object} Object containing the ordered map of results. + */ +export function map(children, func, context) { + let index = 0; + + return React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return func.call(context, child, index++); + }); +} + +/** + * Iterates through children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for context. + */ +export function forEach(children, func, context) { + let index = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + func.call(context, child, index++); + }); +} + +/** + * Count the number of "valid components" in the Children container. + * + * @param {?*} children Children tree container. + * @returns {number} + */ +export function count(children) { + let result = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + ++result; + }); + + return result; +} + +/** + * Finds children that are typically specified as `props.children`, + * but only iterates over children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @returns {array} of children that meet the func return statement + */ +export function filter(children, func, context) { + const result = []; + + forEach(children, (child, index) => { + if (func.call(context, child, index)) { + result.push(child); + } + }); + + return result; +} + +export function find(children, func, context) { + let result = null; + + forEach(children, (child, index) => { + if (result) { + return; + } + if (func.call(context, child, index)) { + result = child; + } + }); + + return result; +} + +export function every(children, func, context) { + let result = true; + + forEach(children, (child, index) => { + if (!result) { + return; + } + if (!func.call(context, child, index)) { + result = false; + } + }); + + return result; +} + +export function some(children, func, context) { + let result = false; + + forEach(children, (child, index) => { + if (result) { + return; + } + + if (func.call(context, child, index)) { + result = true; + } + }); + + return result; +} + +export function toArray(children) { + const result = []; + + forEach(children, (child) => { + result.push(child); + }); + + return result; +} diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js new file mode 100644 index 000000000..512702c87 --- /dev/null +++ b/frontend/src/Helpers/getDisplayName.js @@ -0,0 +1,3 @@ +export default function getDisplayName(Component) { + return Component.displayName || Component.name || 'Component'; +} diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css new file mode 100644 index 000000000..86418a2dd --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css @@ -0,0 +1,24 @@ +.recentFoldersContainer { + margin-top: 15px; +} + +.buttonsContainer { + margin-top: 30px; +} + +.buttonContainer { + display: flex; + justify-content: center; + + margin-top: 10px; +} + +.button { + composes: button from 'Components/Link/Button.css'; + + width: 300px; +} + +.buttonIcon { + margin-right: 5px; +} diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js new file mode 100644 index 000000000..78df1f53e --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import PathInputConnector from 'Components/Form/PathInputConnector'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import RecentFolderRow from './RecentFolderRow'; +import styles from './InteractiveImportSelectFolderModalContent.css'; + +const recentFoldersColumns = [ + { + name: 'folder', + label: 'Folder' + }, + { + name: 'lastUsed', + label: 'Last Used' + }, + { + name: 'actions', + label: '' + } +]; + +class InteractiveImportSelectFolderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: '' + }; + } + + // + // Listeners + + onPathChange = ({ value }) => { + this.setState({ folder: value }); + } + + onRecentPathPress = (folder) => { + this.setState({ folder }); + } + + onQuickImportPress = () => { + this.props.onQuickImportPress(this.state.folder); + } + + onInteractiveImportPress = () => { + this.props.onInteractiveImportPress(this.state.folder); + } + + // + // Render + + render() { + const { + recentFolders, + onRemoveRecentFolderPress, + onModalClose + } = this.props; + + const folder = this.state.folder; + + return ( + + + Manual Import - Select Folder + + + + + + { + !!recentFolders.length && +
+ + + { + recentFolders.map((recentFolder) => { + return ( + + ); + }) + } + +
+
+ } + +
+
+ +
+ +
+ +
+
+
+ + + + +
+ ); + } +} + +InteractiveImportSelectFolderModalContent.propTypes = { + recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onQuickImportPress: PropTypes.func.isRequired, + onInteractiveImportPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportSelectFolderModalContent; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js new file mode 100644 index 000000000..92d729800 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.interactiveImport.recentFolders, + (recentFolders) => { + return { + recentFolders + }; + } + ); +} + +const mapDispatchToProps = { + addRecentFolder, + removeRecentFolder, + executeCommand +}; + +class InteractiveImportSelectFolderModalContentConnector extends Component { + + // + // Listeners + + onQuickImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + + this.props.executeCommand({ + name: commandNames.DOWNLOADED_EPSIODES_SCAN, + path: folder + }); + + this.props.onModalClose(); + } + + onInteractiveImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + this.props.onFolderSelect(folder); + } + + onRemoveRecentFolderPress = (folder) => { + this.props.removeRecentFolder({ folder }); + } + + // + // Render + + render() { + if (this.path) { + return null; + } + + return ( + + ); + } +} + +InteractiveImportSelectFolderModalContentConnector.propTypes = { + path: PropTypes.string, + onFolderSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + addRecentFolder: PropTypes.func.isRequired, + removeRecentFolder: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector); diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.css b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css new file mode 100644 index 000000000..edb55b075 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css @@ -0,0 +1,5 @@ +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 40px; +} diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js new file mode 100644 index 000000000..403bce33d --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import styles from './RecentFolderRow.css'; + +class RecentFolderRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.folder); + } + + onRemovePress = (event) => { + event.stopPropagation(); + + const { + folder, + onRemoveRecentFolderPress + } = this.props; + + onRemoveRecentFolderPress(folder); + } + + // + // Render + + render() { + const { + folder, + lastUsed + } = this.props; + + return ( + + {folder} + + + + + + + + ); + } +} + +RecentFolderRow.propTypes = { + folder: PropTypes.string.isRequired, + lastUsed: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired +}; + +export default RecentFolderRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css new file mode 100644 index 000000000..5bad6c050 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -0,0 +1,73 @@ +.filterContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filterText { + margin-left: 5px; +} + +.footer { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; + + justify-content: space-between; + padding: 15px; +} + +.leftButtons, +.centerButtons, +.rightButtons { + display: flex; + flex: 1 0 33%; + flex-wrap: wrap; +} + +.centerButtons { + justify-content: center; +} + +.rightButtons { + justify-content: flex-end; +} + +.importMode { + composes: select from 'Components/Form/SelectInput.css'; + + width: auto; +} + +.errorMessage { + color: $dangerColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .footer { + .leftButtons, + .centerButtons, + .rightButtons { + flex-direction: column; + } + + .leftButtons { + align-items: flex-start; + } + + .centerButtons { + align-items: center; + } + + .rightButtons { + align-items: flex-end; + } + + a, + button { + margin-left: 0; + + &:first-child { + margin-bottom: 5px; + } + } + } +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js new file mode 100644 index 000000000..fe8ce2ecc --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -0,0 +1,361 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectInput from 'Components/Form/SelectInput'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import SelectedMenuItem from 'Components/Menu/SelectedMenuItem'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import InteractiveImportRow from './InteractiveImportRow'; +import styles from './InteractiveImportModalContent.css'; + +const columns = [ + { + name: 'relativePath', + label: 'Relative Path', + isSortable: true, + isVisible: true + }, + { + name: 'series', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + kind: kinds.DANGER + }), + isVisible: true + } +]; + +const filterExistingFilesOptions = { + ALL: 'all', + NEW: 'new' +}; + +class InteractiveImportModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + invalidRowsSelected: [], + isSelectSeriesModalOpen: false + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onValidRowChange = (id, isValid) => { + this.setState((state) => { + if (isValid) { + return { + invalidRowsSelected: _.without(state.invalidRowsSelected, id) + }; + } + + return { + invalidRowsSelected: [...state.invalidRowsSelected, id] + }; + }); + } + + onImportSelectedPress = () => { + const selected = this.getSelectedIds(); + + this.props.onImportSelectedPress(selected, this.props.importMode); + } + + onFilterExistingFilesChange = (value) => { + this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); + } + + onImportModeChange = ({ value }) => { + this.props.onImportModeChange(value); + } + + onSelectSeriesPress = () => { + this.setState({ isSelectSeriesModalOpen: true }); + } + + onSelectSeriesModalClose = () => { + this.setState({ isSelectSeriesModalOpen: false }); + } + + // + // Render + + render() { + const { + downloadId, + allowSeriesChange, + showFilterExistingFiles, + showImportMode, + filterExistingFiles, + title, + folder, + isFetching, + isPopulated, + error, + items, + sortKey, + sortDirection, + importMode, + interactiveImportErrorMessage, + onSortPress, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + invalidRowsSelected, + isSelectSeriesModalOpen + } = this.state; + + const selectedIds = this.getSelectedIds(); + const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); + + const importModeOptions = [ + { key: 'move', value: 'Move Files' }, + { key: 'copy', value: 'Copy Files' } + ]; + + return ( + + + Manual Import - {title || folder} + + + + { + showFilterExistingFiles && +
+ + + + +
+ { + filterExistingFiles ? 'Unmapped Files Only' : 'All Files' + } +
+
+ + + + All Files + + + + Unmapped Files Only + + +
+
+ } + + { + isFetching && + + } + + { + error && +
{errorMessage}
+ } + + { + isPopulated && !!items.length && !isFetching && !isFetching && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + isPopulated && !items.length && !isFetching && + 'No video files were found in the selected folder' + } +
+ + +
+ { + !downloadId && showImportMode && + + } +
+ +
+ { + allowSeriesChange && + + } +
+ +
+ + + { + interactiveImportErrorMessage && + {interactiveImportErrorMessage} + } + + +
+
+ + +
+ ); + } +} + +InteractiveImportModalContent.propTypes = { + downloadId: PropTypes.string, + allowSeriesChange: PropTypes.bool.isRequired, + showImportMode: PropTypes.bool.isRequired, + showFilterExistingFiles: PropTypes.bool.isRequired, + filterExistingFiles: PropTypes.bool.isRequired, + importMode: PropTypes.string.isRequired, + title: PropTypes.string, + folder: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + interactiveImportErrorMessage: PropTypes.string, + onSortPress: PropTypes.func.isRequired, + onFilterExistingFilesChange: PropTypes.func.isRequired, + onImportModeChange: PropTypes.func.isRequired, + onImportSelectedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +InteractiveImportModalContent.defaultProps = { + allowSeriesChange: true, + showFilterExistingFiles: false, + showImportMode: true, + importMode: 'move' +}; + +export default InteractiveImportModalContent; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js new file mode 100644 index 000000000..3a7b03fb6 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js @@ -0,0 +1,203 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveImportModalContent from './InteractiveImportModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport'), + (interactiveImport) => { + return interactiveImport; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportItems, + setInteractiveImportSort, + setInteractiveImportMode, + clearInteractiveImport, + executeCommand +}; + +class InteractiveImportModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + interactiveImportErrorMessage: null, + filterExistingFiles: true + }; + } + + componentDidMount() { + const { + downloadId, + folder + } = this.props; + + const { + filterExistingFiles + } = this.state; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles + }); + } + + componentDidUpdate(prevProps, prevState) { + const { + filterExistingFiles + } = this.state; + + if (prevState.filterExistingFiles !== filterExistingFiles) { + const { + downloadId, + folder + } = this.props; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles + }); + } + } + + componentWillUnmount() { + this.props.clearInteractiveImport(); + } + + // + // Listeners + + onSortPress = (sortKey, sortDirection) => { + this.props.setInteractiveImportSort({ sortKey, sortDirection }); + } + + onFilterExistingFilesChange = (filterExistingFiles) => { + this.setState({ filterExistingFiles }); + } + + onImportModeChange = (importMode) => { + this.props.setInteractiveImportMode({ importMode }); + } + + onImportSelectedPress = (selected, importMode) => { + const files = []; + + _.forEach(this.props.items, (item) => { + const isSelected = selected.indexOf(item.id) > -1; + + if (isSelected) { + const { + series, + seasonNumber, + episodes, + quality, + language + } = item; + + if (!series) { + this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' }); + return false; + } + + if (isNaN(seasonNumber)) { + this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' }); + return false; + } + + if (!episodes || !episodes.length) { + this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' }); + return false; + } + + if (!quality) { + this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' }); + return false; + } + + if (!language) { + this.setState({ interactiveImportErrorMessage: 'Language must be chosen for each selected file' }); + return false; + } + + files.push({ + path: item.path, + folderName: item.folderName, + seriesId: series.id, + episodeIds: _.map(episodes, 'id'), + quality, + language, + downloadId: this.props.downloadId + }); + } + }); + + if (!files.length) { + return; + } + + this.props.executeCommand({ + name: commandNames.INTERACTIVE_IMPORT, + files, + importMode + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + interactiveImportErrorMessage, + filterExistingFiles + } = this.state; + + return ( + + ); + } +} + +InteractiveImportModalContentConnector.propTypes = { + downloadId: PropTypes.string, + folder: PropTypes.string, + filterExistingFiles: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportItems: PropTypes.func.isRequired, + setInteractiveImportSort: PropTypes.func.isRequired, + clearInteractiveImport: PropTypes.func.isRequired, + setInteractiveImportMode: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +InteractiveImportModalContentConnector.defaultProps = { + filterExistingFiles: true +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css new file mode 100644 index 000000000..89f43cebc --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css @@ -0,0 +1,18 @@ +.relativePath { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.quality, +.language { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + +.label { + composes: label from 'Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js new file mode 100644 index 000000000..0f84b9a8c --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -0,0 +1,251 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Popover from 'Components/Tooltip/Popover'; +import MovieQuality from 'Movie/MovieQuality'; +// import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; +import styles from './InteractiveImportRow.css'; + +class InteractiveImportRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isSelectSeriesModalOpen: false, + isSelectQualityModalOpen: false + }; + } + + componentDidMount() { + const { + id, + series, + quality + } = this.props; + + if ( + series && + quality + ) { + this.props.onSelectedChange({ id, value: true }); + } + } + + componentDidUpdate(prevProps) { + const { + id, + series, + quality, + isSelected, + onValidRowChange + } = this.props; + + if ( + prevProps.series === series && + prevProps.quality === quality && + prevProps.isSelected === isSelected + ) { + return; + } + + const isValid = !!( + series && + quality + ); + + if (isSelected && !isValid) { + onValidRowChange(id, false); + } else { + onValidRowChange(id, true); + } + } + + // + // Control + + selectRowAfterChange = (value) => { + const { + id, + isSelected + } = this.props; + + if (!isSelected && value === true) { + this.props.onSelectedChange({ id, value }); + } + } + + // + // Listeners + + onSelectSeriesPress = () => { + this.setState({ isSelectSeriesModalOpen: true }); + } + + onSelectQualityPress = () => { + this.setState({ isSelectQualityModalOpen: true }); + } + + onSelectSeriesModalClose = (changed) => { + this.setState({ isSelectSeriesModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectQualityModalClose = (changed) => { + this.setState({ isSelectQualityModalOpen: false }); + this.selectRowAfterChange(changed); + } + + // + // Render + + render() { + const { + id, + allowSeriesChange, + relativePath, + series, + quality, + size, + rejections, + isSelected, + onSelectedChange + } = this.props; + + const { + isSelectSeriesModalOpen, + isSelectQualityModalOpen + } = this.state; + + const seriesTitle = series ? series.title : ''; + + const showSeriesPlaceholder = isSelected && !series; + const showQualityPlaceholder = isSelected && !quality; + + return ( + + + + + {relativePath} + + + + { + showSeriesPlaceholder ? : seriesTitle + } + + + + { + showQualityPlaceholder && + + } + + { + !showQualityPlaceholder && !!quality && + + } + + + + {formatBytes(size)} + + + + { + !!rejections.length && + + } + title="Release Rejected" + body={ +
    + { + rejections.map((rejection, index) => { + return ( +
  • + {rejection.reason} +
  • + ); + }) + } +
+ } + position={tooltipPositions.LEFT} + /> + } +
+ + + + 1 : false} + real={quality ? quality.revision.real > 0 : false} + onModalClose={this.onSelectQualityModalClose} + /> +
+ ); + } + +} + +InteractiveImportRow.propTypes = { + id: PropTypes.number.isRequired, + allowSeriesChange: PropTypes.bool.isRequired, + relativePath: PropTypes.string.isRequired, + series: PropTypes.object, + seasonNumber: PropTypes.number, + episodes: PropTypes.arrayOf(PropTypes.object).isRequired, + quality: PropTypes.object, + size: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onValidRowChange: PropTypes.func.isRequired +}; + +InteractiveImportRow.defaultProps = { + episodes: [] +}; + +export default InteractiveImportRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css new file mode 100644 index 000000000..941988144 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css @@ -0,0 +1,7 @@ +.placeholder { + display: inline-block; + margin: -8px 0; + width: 100%; + height: 25px; + border: 2px dashed $dangerColor; +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js new file mode 100644 index 000000000..b6744d156 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './InteractiveImportRowCellPlaceholder.css'; + +function InteractiveImportRowCellPlaceholder() { + return ( + + ); +} + +export default InteractiveImportRowCellPlaceholder; diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js new file mode 100644 index 000000000..0ea6fd9cb --- /dev/null +++ b/frontend/src/InteractiveImport/InteractiveImportModal.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector'; +import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector'; + +class InteractiveImportModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: null + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.isOpen && !this.props.isOpen) { + this.setState({ folder: null }); + } + } + + // + // Listeners + + onFolderSelect = (folder) => { + this.setState({ folder }); + } + + // + // Render + + render() { + const { + isOpen, + folder, + downloadId, + onModalClose, + ...otherProps + } = this.props; + + const folderPath = folder || this.state.folder; + + return ( + + { + folderPath || downloadId ? + : + + } + + ); + } +} + +InteractiveImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + folder: PropTypes.string, + downloadId: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js new file mode 100644 index 000000000..d3e31d2dd --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectQualityModalContentConnector from './SelectQualityModalContentConnector'; + +class SelectQualityModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectQualityModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectQualityModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js new file mode 100644 index 000000000..642e0433e --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class SelectQualityModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + qualityId, + proper, + real + } = props; + + this.state = { + qualityId, + proper, + real + }; + } + + // + // Listeners + + onQualityChange = ({ value }) => { + this.setState({ qualityId: parseInt(value) }); + } + + onProperChange = ({ value }) => { + this.setState({ proper: value }); + } + + onRealChange = ({ value }) => { + this.setState({ real: value }); + } + + onQualitySelect = () => { + this.props.onQualitySelect(this.state); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + onModalClose + } = this.props; + + const { + qualityId, + proper, + real + } = this.state; + + const qualityOptions = items.map(({ id, name }) => { + return { + key: id, + value: name + }; + }); + + return ( + + + Manual Import - Select Quality + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load qualities
+ } + + { + isPopulated && !error && +
+ + Quality + + + + + + Proper + + + + + + Real + + + +
+ } +
+ + + + + + +
+ ); + } +} + +SelectQualityModalContent.propTypes = { + qualityId: PropTypes.number.isRequired, + proper: PropTypes.bool.isRequired, + real: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onQualitySelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectQualityModalContent; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js new file mode 100644 index 000000000..20a49c768 --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js @@ -0,0 +1,95 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import SelectQualityModalContent from './SelectQualityModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + return { + isFetching, + isPopulated, + error, + items: getQualities(schema.items) + }; + } + ); +} + +const mapDispatchToProps = { + fetchQualityProfileSchema, + updateInteractiveImportItem +}; + +class SelectQualityModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.fetchQualityProfileSchema(); + } + } + + // + // Listeners + + onQualitySelect = ({ qualityId, proper, real }) => { + const quality = _.find(this.props.items, + (item) => item.id === qualityId); + + const revision = { + version: proper ? 2 : 1, + real: real ? 1 : 0 + }; + + this.props.updateInteractiveImportItem({ + id: this.props.id, + quality: { + quality, + revision + } + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectQualityModalContentConnector.propTypes = { + id: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchQualityProfileSchema: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector); diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModal.js b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js new file mode 100644 index 000000000..1a1ceffca --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectSeriesModalContentConnector from './SelectSeriesModalContentConnector'; + +class SelectSeriesModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeriesModal; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css new file mode 100644 index 000000000..c22d502f5 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css @@ -0,0 +1,18 @@ +.modalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.filterInput { + composes: input from 'Components/Form/TextInput.css'; + + flex: 0 0 auto; + margin-bottom: 20px; +} + +.scroller { + flex: 1 1 auto; +} diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js new file mode 100644 index 000000000..6c4c75255 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import TextInput from 'Components/Form/TextInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import SelectSeriesRow from './SelectSeriesRow'; +import styles from './SelectSeriesModalContent.css'; + +class SelectSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filter: '' + }; + } + + // + // Listeners + + onFilterChange = ({ value }) => { + this.setState({ filter: value.toLowerCase() }); + } + + // + // Render + + render() { + const { + items, + onMovieSelect, + onModalClose + } = this.props; + + const filter = this.state.filter; + + return ( + + + Manual Import - Select Series + + + + + + + { + items.map((item) => { + return item.title.toLowerCase().includes(filter) ? + ( + + ) : + null; + }) + } + + + + + + + + ); + } +} + +SelectSeriesModalContent.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMovieSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeriesModalContent; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js new file mode 100644 index 000000000..079634183 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import SelectSeriesModalContent from './SelectSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + createAllMoviesSelector(), + (items) => { + return { + items: items.sort((a, b) => { + if (a.sortTitle < b.sortTitle) { + return -1; + } + + if (a.sortTitle > b.sortTitle) { + return 1; + } + + return 0; + }) + }; + } + ); +} + +const mapDispatchToProps = { + updateInteractiveImportItem +}; + +class SelectSeriesModalContentConnector extends Component { + + // + // Listeners + + onMovieSelect = (seriesId) => { + const series = _.find(this.props.items, { id: seriesId }); + + this.props.ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + series, + seasonNumber: undefined, + episodes: [] + }); + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectSeriesModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeriesModalContentConnector); diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.css b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css new file mode 100644 index 000000000..f2573d585 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css @@ -0,0 +1,4 @@ +.series { + padding: 8px; + border-bottom: 1px solid $borderColor; +} diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.js b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js new file mode 100644 index 000000000..48ba77094 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectSeriesRow.css'; + +class SelectSeriesRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onMovieSelect(this.props.id); + } + + // + // Render + + render() { + return ( + + {this.props.title} + + ); + } +} + +SelectSeriesRow.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + onMovieSelect: PropTypes.func.isRequired +}; + +export default SelectSeriesRow; diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.css b/frontend/src/InteractiveSearch/InteractiveSearch.css new file mode 100644 index 000000000..5e647332f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.css @@ -0,0 +1,9 @@ +.filterMenuContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filteredMessage { + margin-top: 10px; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js new file mode 100644 index 000000000..453ebd468 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -0,0 +1,192 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Icon from 'Components/Icon'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageMenuButton from 'Components/Menu/PageMenuButton'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; +import InteractiveSearchRow from './InteractiveSearchRow'; +import styles from './InteractiveSearch.css'; + +const columns = [ + { + name: 'protocol', + label: 'Source', + isSortable: true, + isVisible: true + }, + { + name: 'age', + label: 'Age', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isSortable: true, + isVisible: true + }, + { + name: 'peers', + label: 'Peers', + isSortable: true, + isVisible: true + }, + { + name: 'qualityWeight', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { name: icons.DANGER }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + }, + { + name: 'releaseWeight', + label: React.createElement(Icon, { name: icons.DOWNLOAD }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + } +]; + +function InteractiveSearch(props) { + const { + searchPayload, + isFetching, + isPopulated, + error, + totalReleasesCount, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + type, + longDateFormat, + timeFormat, + onSortPress, + onFilterSelect, + onGrabPress + } = props; + + return ( +
+
+ +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
+ Unable to load results for this episode search. Try again later +
+ } + + { + !isFetching && isPopulated && !totalReleasesCount && +
+ No results found +
+ } + + { + !!totalReleasesCount && isPopulated && !items.length && +
+ All results are hidden by the applied filter +
+ } + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + totalReleasesCount !== items.length && !!items.length && +
+ Some results are hidden by the applied filter +
+ } +
+ ); +} + +InteractiveSearch.propTypes = { + searchPayload: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalReleasesCount: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + type: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +export default InteractiveSearch; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js new file mode 100644 index 000000000..c9f90472b --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as releaseActions from 'Store/Actions/releaseActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import InteractiveSearch from './InteractiveSearch'; + +function createMapStateToProps(appState, { type }) { + return createSelector( + (state) => state.releases.items.length, + createClientSideCollectionSelector('releases', `releases.${type}`), + createUISettingsSelector(), + (totalReleasesCount, releases, uiSettings) => { + return { + totalReleasesCount, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + ...releases + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchReleases(payload) { + dispatch(releaseActions.fetchReleases(payload)); + }, + + onSortPress(sortKey, sortDirection) { + dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection })); + }, + + onFilterSelect(selectedFilterKey) { + const action = props.type === 'episode' ? + releaseActions.setEpisodeReleasesFilter : + releaseActions.setSeasonReleasesFilter; + + dispatch(action({ selectedFilterKey })); + }, + + onGrabPress(payload) { + dispatch(releaseActions.grabRelease(payload)); + } + }; +} + +class InteractiveSearchConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + searchPayload, + isPopulated, + dispatchFetchReleases + } = this.props; + + // If search results are not yet isPopulated fetch them, + // otherwise re-show the existing props. + + if (!isPopulated) { + dispatchFetchReleases(searchPayload); + } + } + + // + // Render + + render() { + const { + dispatchFetchReleases, + ...otherProps + } = this.props; + + return ( + + + ); + } +} + +InteractiveSearchConnector.propTypes = { + searchPayload: PropTypes.object.isRequired, + isPopulated: PropTypes.bool.isRequired, + dispatchFetchReleases: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js new file mode 100644 index 000000000..dcbcf340f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setEpisodeReleasesFilter, setSeasonReleasesFilter } from 'Store/Actions/releaseActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.releases.items, + (state) => state.releases.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'releases' + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchSetFilter(payload) { + const action = props.type === 'episode' ? + setEpisodeReleasesFilter: + setSeasonReleasesFilter; + + dispatch(action(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css new file mode 100644 index 000000000..c77b73e7d --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -0,0 +1,25 @@ +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.quality { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + +.rejected, +.download { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.age, +.size { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js new file mode 100644 index 000000000..f303394c7 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -0,0 +1,253 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import Peers from './Peers'; +import styles from './InteractiveSearchRow.css'; + +function getDownloadIcon(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return 'Added to downloaded queue'; + } else if (grabError) { + return grabError; + } + + return 'Add to downloaded queue'; +} + +class InteractiveSearchRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmGrabModalOpen: false + }; + } + + // + // Listeners + + onGrabPress = () => { + const { + guid, + indexerId, + onGrabPress + } = this.props; + + onGrabPress({ + guid, + indexerId + }); + } + + onConfirmGrabPress = () => { + this.setState({ isConfirmGrabModalOpen: true }); + } + + onGrabConfirm = () => { + this.setState({ isConfirmGrabModalOpen: false }); + + const { + guid, + indexerId, + searchPayload, + onGrabPress + } = this.props; + + onGrabPress({ + guid, + indexerId, + ...searchPayload + }); + } + + onGrabCancel = () => { + this.setState({ isConfirmGrabModalOpen: false }); + } + + // + // Render + + render() { + const { + protocol, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + indexer, + size, + seeders, + leechers, + quality, + rejections, + downloadAllowed, + isGrabbing, + isGrabbed, + longDateFormat, + timeFormat, + grabError + } = this.props; + + return ( + + + + + + + {formatAge(age, ageHours, ageMinutes)} + + + + + {title} + + + + + {indexer} + + + + {formatBytes(size)} + + + + { + protocol === 'torrent' && + + } + + + + + + + + { + !!rejections.length && + + } + title="Release Rejected" + body={ +
    + { + rejections.map((rejection, index) => { + return ( +
  • + {rejection} +
  • + ); + }) + } +
+ } + position={tooltipPositions.LEFT} + /> + } +
+ + + + + + +
+ ); + } +} + +InteractiveSearchRow.propTypes = { + guid: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + ageHours: PropTypes.number.isRequired, + ageMinutes: PropTypes.number.isRequired, + publishDate: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + infoUrl: PropTypes.string.isRequired, + indexerId: PropTypes.number.isRequired, + indexer: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + seeders: PropTypes.number, + leechers: PropTypes.number, + quality: PropTypes.object.isRequired, + rejections: PropTypes.arrayOf(PropTypes.string).isRequired, + downloadAllowed: PropTypes.bool.isRequired, + isGrabbing: PropTypes.bool.isRequired, + isGrabbed: PropTypes.bool.isRequired, + grabError: PropTypes.string, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + searchPayload: PropTypes.object.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +InteractiveSearchRow.defaultProps = { + rejections: [], + isGrabbing: false, + isGrabbed: false +}; + +export default InteractiveSearchRow; diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/InteractiveSearch/Peers.js new file mode 100644 index 000000000..66f7cc9f5 --- /dev/null +++ b/frontend/src/InteractiveSearch/Peers.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getKind(seeders) { + if (seeders > 50) { + return kinds.PRIMARY; + } + + if (seeders > 10) { + return kinds.INFO; + } + + if (seeders > 0) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +function getPeersTooltipPart(peers, peersUnit) { + if (peers == null) { + return `unknown ${peersUnit}s`; + } + + if (peers === 1) { + return `1 ${peersUnit}`; + } + + return `${peers} ${peersUnit}s`; +} + +function Peers(props) { + const { + seeders, + leechers + } = props; + + const kind = getKind(seeders); + + return ( + + ); +} + +Peers.propTypes = { + seeders: PropTypes.number, + leechers: PropTypes.number +}; + +export default Peers; diff --git a/frontend/src/Movie/Delete/DeleteMovieModal.js b/frontend/src/Movie/Delete/DeleteMovieModal.js new file mode 100644 index 000000000..621b3d8f7 --- /dev/null +++ b/frontend/src/Movie/Delete/DeleteMovieModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import DeleteMovieModalContentConnector from './DeleteMovieModalContentConnector'; + +function DeleteMovieModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteMovieModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteMovieModal; diff --git a/frontend/src/Movie/Delete/DeleteMovieModalContent.css b/frontend/src/Movie/Delete/DeleteMovieModalContent.css new file mode 100644 index 000000000..dbfef0871 --- /dev/null +++ b/frontend/src/Movie/Delete/DeleteMovieModalContent.css @@ -0,0 +1,12 @@ +.pathContainer { + margin-bottom: 20px; +} + +.pathIcon { + margin-right: 8px; +} + +.deleteFilesMessage { + margin-top: 20px; + color: $dangerColor; +} diff --git a/frontend/src/Movie/Delete/DeleteMovieModalContent.js b/frontend/src/Movie/Delete/DeleteMovieModalContent.js new file mode 100644 index 000000000..5cd470f1a --- /dev/null +++ b/frontend/src/Movie/Delete/DeleteMovieModalContent.js @@ -0,0 +1,144 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteMovieModalContent.css'; + +class DeleteMovieModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onDeleteMovieConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + + this.setState({ deleteFiles: false }); + this.props.onDeletePress(deleteFiles); + } + + // + // Render + + render() { + const { + title, + path, + statistics, + onModalClose + } = this.props; + + const { + episodeFileCount, + sizeOnDisk + } = statistics; + + const deleteFiles = this.state.deleteFiles; + let deleteFilesLabel = `Delete ${episodeFileCount} Movie Files`; + let deleteFilesHelpText = 'Delete the movie files and movie folder'; + + if (episodeFileCount === 0) { + deleteFilesLabel = 'Delete Movie Folder'; + deleteFilesHelpText = 'Delete the movie folder and it\'s contents'; + } + + return ( + + + Delete - {title} + + + +
+ + + {path} +
+ + + {deleteFilesLabel} + + + + + { + deleteFiles && +
+
The movie folder {path} and all it's content will be deleted.
+ + { + !!episodeFileCount && +
{episodeFileCount} movie files totaling {formatBytes(sizeOnDisk)}
+ } +
+ } + +
+ + + + + + +
+ ); + } +} + +DeleteMovieModalContent.propTypes = { + title: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + onDeletePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +DeleteMovieModalContent.defaultProps = { + statistics: { + episodeFileCount: 0 + } +}; + +export default DeleteMovieModalContent; diff --git a/frontend/src/Movie/Delete/DeleteMovieModalContentConnector.js b/frontend/src/Movie/Delete/DeleteMovieModalContentConnector.js new file mode 100644 index 000000000..d02c30294 --- /dev/null +++ b/frontend/src/Movie/Delete/DeleteMovieModalContentConnector.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import { deleteMovie } from 'Store/Actions/movieActions'; +import DeleteMovieModalContent from './DeleteMovieModalContent'; + +function createMapStateToProps() { + return createSelector( + createMovieSelector(), + (movie) => { + return movie; + } + ); +} + +const mapDispatchToProps = { + deleteMovie +}; + +class DeleteMovieModalContentConnector extends Component { + + // + // Listeners + + onDeletePress = (deleteFiles) => { + this.props.deleteMovie({ + id: this.props.movieId, + deleteFiles + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeleteMovieModalContentConnector.propTypes = { + movieId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired, + deleteMovie: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeleteMovieModalContentConnector); diff --git a/frontend/src/Movie/Details/MovieAlternateTitles.css b/frontend/src/Movie/Details/MovieAlternateTitles.css new file mode 100644 index 000000000..1af1ae68b --- /dev/null +++ b/frontend/src/Movie/Details/MovieAlternateTitles.css @@ -0,0 +1,3 @@ +.alternateTitle { + white-space: nowrap; +} diff --git a/frontend/src/Movie/Details/MovieAlternateTitles.js b/frontend/src/Movie/Details/MovieAlternateTitles.js new file mode 100644 index 000000000..39cd0f93e --- /dev/null +++ b/frontend/src/Movie/Details/MovieAlternateTitles.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './MovieAlternateTitles.css'; + +function MovieAlternateTitles({ alternateTitles }) { + return ( +
    + { + alternateTitles.map((alternateTitle) => { + return ( +
  • + {alternateTitle} +
  • + ); + }) + } +
+ ); +} + +MovieAlternateTitles.propTypes = { + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default MovieAlternateTitles; diff --git a/frontend/src/Movie/Details/MovieDetails.css b/frontend/src/Movie/Details/MovieDetails.css new file mode 100644 index 000000000..f161e524a --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetails.css @@ -0,0 +1,155 @@ +.innerContentBody { + padding: 0; +} + +.header { + position: relative; + width: 100%; + height: 425px; +} + +.backdrop { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-size: cover; +} + +.backdropOverlay { + position: absolute; + width: 100%; + height: 100%; + background: $black; + opacity: 0.7; +} + +.headerContent { + display: flex; + padding: 30px; + width: 100%; + height: 100%; + color: $white; +} + +.poster { + flex-shrink: 0; + margin-right: 35px; + width: 250px; + height: 368px; +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.titleContainer { + display: flex; + margin-bottom: 5px; +} + +.title { + font-weight: 300; + font-size: 50px; + line-height: 50px; +} + +.toggleMonitoredContainer { + align-self: center; + margin-right: 10px; +} + +.monitorToggleButton { + composes: toggleButton from 'Components/MonitorToggleButton.css'; + + width: 40px; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.alternateTitlesIconContainer { + align-self: flex-end; + margin-left: 20px; +} + +.seriesNavigationButtons { + white-space: nowrap; +} + +.seriesNavigationButton { + composes: button from 'Components/Link/IconButton.css'; + + margin-left: 5px; + width: 30px; + color: #e1e2e3; + white-space: nowrap; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.details { + margin-bottom: 8px; + font-weight: 300; + font-size: 20px; +} + +.runtime { + margin-right: 15px; +} + +.detailsLabel { + composes: label from 'Components/Label.css'; + + margin: 5px 10px 5px 0; +} + +.path, +.sizeOnDisk, +.qualityProfileName, +.network, +.links, +.tags { + margin-left: 8px; + font-weight: 300; + font-size: 17px; +} + +.overview { + flex: 1 0 auto; + margin-top: 8px; + min-height: 0; + font-size: $intermediateFontSize; +} + +.contentContainer { + padding: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentContainer { + padding: 20px 0; + } + + .headerContent { + padding: 15px; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .poster { + display: none; + } +} diff --git a/frontend/src/Movie/Details/MovieDetails.js b/frontend/src/Movie/Details/MovieDetails.js new file mode 100644 index 000000000..6970b8b48 --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetails.js @@ -0,0 +1,593 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import fonts from 'Styles/Variables/fonts'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import Measure from 'Components/Measure'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import MovieFileEditorModal from 'MovieFile/Editor/MovieFileEditorModal'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import MoviePoster from 'Movie/MoviePoster'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import MovieHistoryModal from 'Movie/History/MovieHistoryModal'; +import MovieAlternateTitles from './MovieAlternateTitles'; +import MovieTagsConnector from './MovieTagsConnector'; +import MovieDetailsLinks from './MovieDetailsLinks'; +import styles from './MovieDetails.css'; +import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images) { + const fanartImage = _.find(images, { coverType: 'fanart' }); + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +function getExpandedState(newState) { + return { + allExpanded: newState.allSelected, + allCollapsed: newState.allUnselected, + expandedState: newState.selectedState + }; +} + +class MovieDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isManageEpisodesOpen: false, + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: false, + isMovieHistoryModalOpen: false, + isInteractiveImportModalOpen: false, + allExpanded: false, + allCollapsed: false, + expandedState: {}, + overviewHeight: 0 + }; + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onManageEpisodesPress = () => { + this.setState({ isManageEpisodesOpen: true }); + } + + onManageEpisodesModalClose = () => { + this.setState({ isManageEpisodesOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onDeleteMoviePress = () => { + this.setState({ + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: true + }); + } + + onDeleteMovieModalClose = () => { + this.setState({ isDeleteMovieModalOpen: false }); + } + + onMovieHistoryPress = () => { + this.setState({ isMovieHistoryModalOpen: true }); + } + + onMovieHistoryModalClose = () => { + this.setState({ isMovieHistoryModalOpen: false }); + } + + onExpandAllPress = () => { + const { + allExpanded, + expandedState + } = this.state; + + this.setState(getExpandedState(selectAll(expandedState, !allExpanded))); + } + + onExpandPress = (seasonNumber, isExpanded) => { + this.setState((state) => { + const convertedState = { + allSelected: state.allExpanded, + allUnselected: state.allCollapsed, + selectedState: state.expandedState + }; + + const newState = toggleSelected(convertedState, [], seasonNumber, isExpanded, false); + + return getExpandedState(newState); + }); + } + + onMeasure = ({ height }) => { + this.setState({ overviewHeight: height }); + } + + // + // Render + + render() { + const { + id, + tmdbId, + imdbId, + title, + runtime, + ratings, + path, + sizeOnDisk, + qualityProfileId, + monitored, + studio, + overview, + images, + alternateTitles, + tags, + isSaving, + isRefreshing, + isSearching, + isFetching, + isPopulated, + movieFilesError, + hasMovieFiles, + previousMovie, + nextMovie, + onMonitorTogglePress, + onRefreshPress, + onSearchPress + } = this.props; + + const { + isOrganizeModalOpen, + isManageEpisodesOpen, + isEditMovieModalOpen, + isDeleteMovieModalOpen, + isMovieHistoryModalOpen, + isInteractiveImportModalOpen, + overviewHeight + } = this.state; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ +
+ {title} +
+ + { + !!alternateTitles.length && +
+ + } + title="Alternate Titles" + body={} + position={tooltipPositions.BOTTOM} + /> +
+ } +
+ +
+ + + +
+
+ +
+
+ { + !!runtime && + + {runtime} Minutes + + } + + +
+
+ +
+ + + + + + + + + { + !!studio && + + } + + + + + + Links + + + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + { + !!tags.length && + + + + + Tags + + + } + tooltip={} + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + } +
+ + +
+ +
+
+
+
+
+ +
+ { + !isPopulated && !movieFilesError && + + } + + { + !isFetching && movieFilesError && +
Loading movie files failed
+ } + +
+ + + + + + + + + + + + + + + ); + } +} + +MovieDetails.propTypes = { + id: PropTypes.number.isRequired, + tmdbId: PropTypes.number.isRequired, + imdbId: PropTypes.string, + title: PropTypes.string.isRequired, + runtime: PropTypes.number.isRequired, + ratings: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number.isRequired, + qualityProfileId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + studio: PropTypes.string, + overview: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + isSaving: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isSearching: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + movieFilesError: PropTypes.object, + hasMovieFiles: PropTypes.bool.isRequired, + previousMovie: PropTypes.object.isRequired, + nextMovie: PropTypes.object.isRequired, + onMonitorTogglePress: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +MovieDetails.defaultProps = { + tag: [], + isSaving: false +}; + +export default MovieDetails; diff --git a/frontend/src/Movie/Details/MovieDetailsConnector.js b/frontend/src/Movie/Details/MovieDetailsConnector.js new file mode 100644 index 000000000..9c44b6083 --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetailsConnector.js @@ -0,0 +1,229 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { fetchMovieFiles, clearMovieFiles } from 'Store/Actions/movieFileActions'; +import { toggleMovieMonitored } from 'Store/Actions/movieActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import MovieDetails from './MovieDetails'; + +const selectMovieFiles = createSelector( + (state) => state.movieFiles, + (movieFiles) => { + const { + items, + isFetching, + isPopulated, + error + } = movieFiles; + + const hasMovieFiles = !!items.length; + + return { + isMovieFilesFetching: isFetching, + isMovieFilesPopulated: isPopulated, + movieFilesError: error, + hasMovieFiles + }; + } +); + +function createMapStateToProps() { + return createSelector( + (state, { titleSlug }) => titleSlug, + selectMovieFiles, + createAllMoviesSelector(), + createCommandsSelector(), + (titleSlug, movieFiles, allMovies, commands) => { + const sortedMovies = _.orderBy(allMovies, 'sortTitle'); + const movieIndex = _.findIndex(sortedMovies, { titleSlug }); + const movie = sortedMovies[movieIndex]; + + if (!movie) { + return {}; + } + + const { + isMovieFilesFetching, + isMovieFilesPopulated, + episodeFilesError, + hasMovieFiles + } = movieFiles; + + const previousMovie = sortedMovies[movieIndex - 1] || _.last(sortedMovies); + const nextMovie = sortedMovies[movieIndex + 1] || _.first(sortedMovies); + const isMovieRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_MOVIE, movieId: movie.id })); + const movieRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_MOVIE }); + const allMoviesRefreshing = ( + isCommandExecuting(movieRefreshingCommand) && + !movieRefreshingCommand.body.movieId + ); + const isRefreshing = isMovieRefreshing || allMoviesRefreshing; + const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.MOVIE_SEARCH, movieIds: [movie.id] })); + const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, movieId: movie.id })); + const isRenamingMovieCommand = findCommand(commands, { name: commandNames.RENAME_SERIES }); + const isRenamingMovie = ( + isCommandExecuting(isRenamingMovieCommand) && + isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1 + ); + + const isFetching = isMovieFilesFetching; + const isPopulated = isMovieFilesPopulated; + const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => { + if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) && + (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) { + acc.push(alternateTitle.title); + } + + return acc; + }, []); + + return { + ...movie, + alternateTitles, + isMovieRefreshing, + allMoviesRefreshing, + isRefreshing, + isSearching, + isRenamingFiles, + isRenamingMovie, + isFetching, + isPopulated, + episodeFilesError, + hasMovieFiles, + previousMovie, + nextMovie + }; + } + ); +} + +const mapDispatchToProps = { + fetchMovieFiles, + clearMovieFiles, + toggleMovieMonitored, + fetchQueueDetails, + clearQueueDetails, + executeCommand +}; + +class MovieDetailsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + registerPagePopulator(this.populate); + this.populate(); + } + + componentDidUpdate(prevProps) { + const { + id, + isMovieRefreshing, + allMoviesRefreshing, + isRenamingFiles, + isRenamingMovie + } = this.props; + + if ( + (prevProps.isMovieRefreshing && !isMovieRefreshing) || + (prevProps.allMoviesRefreshing && !allMoviesRefreshing) || + (prevProps.isRenamingFiles && !isRenamingFiles) || + (prevProps.isRenamingMovie && !isRenamingMovie) + ) { + this.populate(); + } + + // If the id has changed we need to clear the episodes/episode + // files and fetch from the server. + + if (prevProps.id !== id) { + this.unpopulate(); + this.populate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.populate); + this.unpopulate(); + } + + // + // Control + + populate = () => { + const movieId = this.props.id; + + this.props.fetchMovieFiles({ movieId }); + this.props.fetchQueueDetails({ movieId }); + } + + unpopulate = () => { + this.props.clearMovieFiles(); + this.props.clearQueueDetails(); + } + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleMovieMonitored({ + movieId: this.props.id, + monitored + }); + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_MOVIE, + movieId: this.props.id + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.MOVIE_SEARCH, + movieIds: [this.props.id] + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MovieDetailsConnector.propTypes = { + id: PropTypes.number.isRequired, + titleSlug: PropTypes.string.isRequired, + isMovieRefreshing: PropTypes.bool.isRequired, + allMoviesRefreshing: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isRenamingFiles: PropTypes.bool.isRequired, + isRenamingMovie: PropTypes.bool.isRequired, + fetchMovieFiles: PropTypes.func.isRequired, + clearMovieFiles: PropTypes.func.isRequired, + toggleMovieMonitored: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MovieDetailsConnector); diff --git a/frontend/src/Movie/Details/MovieDetailsLinks.css b/frontend/src/Movie/Details/MovieDetailsLinks.css new file mode 100644 index 000000000..0f65b9154 --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetailsLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from 'Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Movie/Details/MovieDetailsLinks.js b/frontend/src/Movie/Details/MovieDetailsLinks.js new file mode 100644 index 000000000..ce51fe900 --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetailsLinks.js @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import styles from './MovieDetailsLinks.css'; + +function MovieDetailsLinks(props) { + const { + tmdbId, + imdbId + } = props; + + return ( +
+ + + + + + + + + { + !!imdbId && + + + + } +
+ ); +} + +MovieDetailsLinks.propTypes = { + tmdbId: PropTypes.number.isRequired, + imdbId: PropTypes.string +}; + +export default MovieDetailsLinks; diff --git a/frontend/src/Movie/Details/MovieDetailsPageConnector.js b/frontend/src/Movie/Details/MovieDetailsPageConnector.js new file mode 100644 index 000000000..a9c4d6526 --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetailsPageConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import NotFound from 'Components/NotFound'; +import MovieDetailsConnector from './MovieDetailsConnector'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + createAllMoviesSelector(), + (match, allMovies) => { + const titleSlug = match.params.titleSlug; + const movieIndex = _.findIndex(allMovies, { titleSlug }); + + if (movieIndex > -1) { + return { + titleSlug + }; + } + + return {}; + } + ); +} + +const mapDispatchToProps = { + push +}; + +class MovieDetailsPageConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if (!this.props.titleSlug) { + this.props.push(`${window.Radarr.urlBase}/`); + return; + } + } + + // + // Render + + render() { + const { + titleSlug + } = this.props; + + if (!titleSlug) { + return ( + + ); + } + + return ( + + ); + } +} + +MovieDetailsPageConnector.propTypes = { + titleSlug: PropTypes.string, + match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MovieDetailsPageConnector); diff --git a/frontend/src/Movie/Details/MovieTags.js b/frontend/src/Movie/Details/MovieTags.js new file mode 100644 index 000000000..93bb00ae0 --- /dev/null +++ b/frontend/src/Movie/Details/MovieTags.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function MovieTags({ tags }) { + return ( +
+ { + tags.map((tag) => { + return ( + + ); + }) + } +
+ ); +} + +MovieTags.propTypes = { + tags: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default MovieTags; diff --git a/frontend/src/Movie/Details/MovieTagsConnector.js b/frontend/src/Movie/Details/MovieTagsConnector.js new file mode 100644 index 000000000..a0f8c6a63 --- /dev/null +++ b/frontend/src/Movie/Details/MovieTagsConnector.js @@ -0,0 +1,30 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import MovieTags from './MovieTags'; + +function createMapStateToProps() { + return createSelector( + createMovieSelector(), + createTagsSelector(), + (series, tagList) => { + const tags = _.reduce(series.tags, (acc, tag) => { + const matchingTag = _.find(tagList, { id: tag }); + + if (matchingTag) { + acc.push(matchingTag.label); + } + + return acc; + }, []); + + return { + tags + }; + } + ); +} + +export default connect(createMapStateToProps)(MovieTags); diff --git a/frontend/src/Movie/Edit/EditMovieModal.js b/frontend/src/Movie/Edit/EditMovieModal.js new file mode 100644 index 000000000..24d9e432a --- /dev/null +++ b/frontend/src/Movie/Edit/EditMovieModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditMovieModalContentConnector from './EditMovieModalContentConnector'; + +function EditMovieModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditMovieModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditMovieModal; diff --git a/frontend/src/Movie/Edit/EditMovieModalConnector.js b/frontend/src/Movie/Edit/EditMovieModalConnector.js new file mode 100644 index 000000000..affea9d0c --- /dev/null +++ b/frontend/src/Movie/Edit/EditMovieModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMovieModal from './EditMovieModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditMovieModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'movies' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMovieModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditMovieModalConnector); diff --git a/frontend/src/Movie/Edit/EditMovieModalContent.css b/frontend/src/Movie/Edit/EditMovieModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Movie/Edit/EditMovieModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Movie/Edit/EditMovieModalContent.js b/frontend/src/Movie/Edit/EditMovieModalContent.js new file mode 100644 index 000000000..8fb17a9c6 --- /dev/null +++ b/frontend/src/Movie/Edit/EditMovieModalContent.js @@ -0,0 +1,182 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import MoveMovieModal from 'Movie/MoveSeries/MoveSeriesModal'; +import styles from './EditMovieModalContent.css'; + +class EditMovieModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmMoveModalOpen: false + }; + } + + // + // Listeners + + onSavePress = () => { + const { + isPathChanging, + onSavePress + } = this.props; + + if (isPathChanging && !this.state.isConfirmMoveModalOpen) { + this.setState({ isConfirmMoveModalOpen: true }); + } else { + this.setState({ isConfirmMoveModalOpen: false }); + + onSavePress(false); + } + } + + onMoveSeriesPress = () => { + this.setState({ isConfirmMoveModalOpen: false }); + + this.props.onSavePress(true); + } + + // + // Render + + render() { + const { + title, + item, + isSaving, + originalPath, + onInputChange, + onModalClose, + onDeleteMoviePress, + ...otherProps + } = this.props; + + const { + monitored, + qualityProfileId, + // Id, + path, + tags + } = item; + + return ( + + + Edit - {title} + + + +
+ + Monitored + + + + + + Quality Profile + + + + + + Path + + + + + + Tags + + + +
+
+ + + + + + + + Save + + + + +
+ ); + } +} + +EditMovieModalContent.propTypes = { + movieId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + item: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + isPathChanging: PropTypes.bool.isRequired, + originalPath: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteMoviePress: PropTypes.func.isRequired +}; + +export default EditMovieModalContent; diff --git a/frontend/src/Movie/Edit/EditMovieModalContentConnector.js b/frontend/src/Movie/Edit/EditMovieModalContentConnector.js new file mode 100644 index 000000000..7f6b11860 --- /dev/null +++ b/frontend/src/Movie/Edit/EditMovieModalContentConnector.js @@ -0,0 +1,115 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import { setMovieValue, saveMovie } from 'Store/Actions/movieActions'; +import EditMovieModalContent from './EditMovieModalContent'; + +function createIsPathChangingSelector() { + return createSelector( + (state) => state.movies.pendingChanges, + createMovieSelector(), + (pendingChanges, movie) => { + const path = pendingChanges.path; + + if (path == null) { + return false; + } + + return movie.path !== path; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.movies, + createMovieSelector(), + createIsPathChangingSelector(), + (seriesState, movie, isPathChanging) => { + const { + isSaving, + saveError, + pendingChanges + } = seriesState; + + const seriesSettings = _.pick(movie, [ + 'monitored', + 'qualityProfileId', + 'path', + 'tags' + ]); + + const settings = selectSettings(seriesSettings, pendingChanges, saveError); + + return { + title: movie.title, + isSaving, + saveError, + isPathChanging, + originalPath: movie.path, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetMovieValue: setMovieValue, + dispatchSaveMovie: saveMovie +}; + +class EditMovieModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetMovieValue({ name, value }); + } + + onSavePress = (moveFiles) => { + this.props.dispatchSaveMovie({ + id: this.props.movieId, + moveFiles + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMovieModalContentConnector.propTypes = { + movieId: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchSetMovieValue: PropTypes.func.isRequired, + dispatchSaveMovie: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditMovieModalContentConnector); diff --git a/frontend/src/Movie/Editor/Delete/DeleteMovieModal.js b/frontend/src/Movie/Editor/Delete/DeleteMovieModal.js new file mode 100644 index 000000000..8a9a80b16 --- /dev/null +++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteMovieModalContentConnector from './DeleteMovieModalContentConnector'; + +function DeleteMovieModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteMovieModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteMovieModal; diff --git a/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.css b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.css new file mode 100644 index 000000000..950fdc27d --- /dev/null +++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.css @@ -0,0 +1,13 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: $dangerColor; +} diff --git a/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.js b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.js new file mode 100644 index 000000000..94ad87a34 --- /dev/null +++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContent.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteMovieModalContent.css'; + +class DeleteMovieModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onDeleteMovieConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + + this.setState({ deleteFiles: false }); + this.props.onDeleteSelectedPress(deleteFiles); + } + + // + // Render + + render() { + const { + series, + onModalClose + } = this.props; + const deleteFiles = this.state.deleteFiles; + + return ( + + + Delete Selected Series + + + +
+ + {`Delete Series Folder${series.length > 1 ? 's' : ''}`} + + 1 ? 's' : ''} and all contents`} + kind={kinds.DANGER} + onChange={this.onDeleteFilesChange} + /> + +
+ +
+ {`Are you sure you want to delete ${series.length} selected series${deleteFiles ? ' and all contents' : ''}?`} +
+ +
    + { + series.map((s) => { + return ( +
  • + {s.title} + + { + deleteFiles && + + - + + {s.path} + + + } +
  • + ); + }) + } +
+
+ + + + + + +
+ ); + } +} + +DeleteMovieModalContent.propTypes = { + series: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSelectedPress: PropTypes.func.isRequired +}; + +export default DeleteMovieModalContent; diff --git a/frontend/src/Movie/Editor/Delete/DeleteMovieModalContentConnector.js b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContentConnector.js new file mode 100644 index 000000000..2ff44ea29 --- /dev/null +++ b/frontend/src/Movie/Editor/Delete/DeleteMovieModalContentConnector.js @@ -0,0 +1,45 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import { bulkDeleteMovie } from 'Store/Actions/movieEditorActions'; +import DeleteMovieModalContent from './DeleteMovieModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllMoviesSelector(), + (seriesIds, allMovies) => { + const selectedMovie = _.intersectionWith(allMovies, seriesIds, (s, id) => { + return s.id === id; + }); + + const sortedSeries = _.orderBy(selectedMovie, 'sortTitle'); + const series = _.map(sortedSeries, (s) => { + return { + title: s.title, + path: s.path + }; + }); + + return { + series + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteSelectedPress(deleteFiles) { + dispatch(bulkDeleteMovie({ + seriesIds: props.seriesIds, + deleteFiles + })); + + props.onModalClose(); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteMovieModalContent); diff --git a/frontend/src/Movie/Editor/Organize/OrganizeSeriesModal.js b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModal.js new file mode 100644 index 000000000..c970392ec --- /dev/null +++ b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizeSeriesModalContentConnector from './OrganizeSeriesModalContentConnector'; + +function OrganizeSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +OrganizeSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizeSeriesModal; diff --git a/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.css b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.css new file mode 100644 index 000000000..0b896f4ef --- /dev/null +++ b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.css @@ -0,0 +1,8 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.js b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.js new file mode 100644 index 000000000..10a459d52 --- /dev/null +++ b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContent.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './OrganizeSeriesModalContent.css'; + +function OrganizeSeriesModalContent(props) { + const { + seriesTitles, + onModalClose, + onOrganizeSeriesPress + } = props; + + return ( + + + Organize Selected Series + + + + + Tip: To preview a rename... select "Cancel" then any series title and use the + + + +
+ Are you sure you want to organize all files in the {seriesTitles.length} selected series? +
+ +
    + { + seriesTitles.map((title) => { + return ( +
  • + {title} +
  • + ); + }) + } +
+
+ + + + + + +
+ ); +} + +OrganizeSeriesModalContent.propTypes = { + seriesTitles: PropTypes.arrayOf(PropTypes.string).isRequired, + onModalClose: PropTypes.func.isRequired, + onOrganizeSeriesPress: PropTypes.func.isRequired +}; + +export default OrganizeSeriesModalContent; diff --git a/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContentConnector.js b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContentConnector.js new file mode 100644 index 000000000..95fa7ebd6 --- /dev/null +++ b/frontend/src/Movie/Editor/Organize/OrganizeSeriesModalContentConnector.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizeSeriesModalContent from './OrganizeSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllMoviesSelector(), + (seriesIds, allMovies) => { + const series = _.intersectionWith(allMovies, seriesIds, (s, id) => { + return s.id === id; + }); + + const sortedSeries = _.orderBy(series, 'sortTitle'); + const seriesTitles = _.map(sortedSeries, 'title'); + + return { + seriesTitles + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class OrganizeSeriesModalContentConnector extends Component { + + // + // Listeners + + onOrganizeSeriesPress = () => { + this.props.executeCommand({ + name: commandNames.RENAME_SERIES, + seriesIds: this.props.seriesIds + }); + + this.props.onModalClose(true); + } + + // + // Render + + render(props) { + return ( + + ); + } +} + +OrganizeSeriesModalContentConnector.propTypes = { + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeSeriesModalContentConnector); diff --git a/frontend/src/Movie/Editor/SeriesEditor.js b/frontend/src/Movie/Editor/SeriesEditor.js new file mode 100644 index 000000000..401cb73ab --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditor.js @@ -0,0 +1,268 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import NoMovie from 'Movie/NoMovie'; +import OrganizeSeriesModal from './Organize/OrganizeSeriesModal'; +import SeriesEditorRowConnector from './SeriesEditorRowConnector'; +import SeriesEditorFooter from './SeriesEditorFooter'; +import SeriesEditorFilterModalConnector from './SeriesEditorFilterModalConnector'; + +function getColumns() { + return [ + { + name: 'status', + isSortable: true, + isVisible: true + }, + { + name: 'sortTitle', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: true + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: true + } + ]; +} + +class SeriesEditor extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isOrganizingSeriesModalOpen: false, + columns: getColumns() + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + const hasFinishedDeleting = prevProps.isDeleting && + !isDeleting && + !deleteError; + + if (hasFinishedDeleting) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSaveSelected = (changes) => { + this.props.onSaveSelected({ + seriesIds: this.getSelectedIds(), + ...changes + }); + } + + onOrganizeSeriesPress = () => { + this.setState({ isOrganizingSeriesModalOpen: true }); + } + + onOrganizeSeriesModalClose = (organized) => { + this.setState({ isOrganizingSeriesModalOpen: false }); + + if (organized === true) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + isSaving, + saveError, + isDeleting, + deleteError, + isOrganizingSeries, + onSortPress, + onFilterSelect + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + columns + } = this.state; + + const selectedMovieIds = this.getSelectedIds(); + + return ( + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load the calendar
+ } + + { + !error && isPopulated && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + + + +
+ ); + } +} + +SeriesEditor.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingSeries: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSaveSelected: PropTypes.func.isRequired +}; + +export default SeriesEditor; diff --git a/frontend/src/Movie/Editor/SeriesEditorConnector.js b/frontend/src/Movie/Editor/SeriesEditorConnector.js new file mode 100644 index 000000000..fc1901ac9 --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { setSeriesEditorSort, setSeriesEditorFilter, saveSeriesEditor } from 'Store/Actions/movieEditorActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import SeriesEditor from './SeriesEditor'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('series', 'movieEditor'), + createCommandExecutingSelector(commandNames.RENAME_SERIES), + (series, isOrganizingSeries) => { + return { + isOrganizingSeries, + ...series + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetSeriesEditorSort: setSeriesEditorSort, + dispatchSetSeriesEditorFilter: setSeriesEditorFilter, + dispatchSaveMovieEditor: saveSeriesEditor, + dispatchFetchRootFolders: fetchRootFolders, + dispatchExecuteCommand: executeCommand +}; + +class SeriesEditorConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Listeners + + onSortPress = (sortKey) => { + this.props.dispatchSetSeriesEditorSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.dispatchSetSeriesEditorFilter({ selectedFilterKey }); + } + + onSaveSelected = (payload) => { + this.props.dispatchSaveMovieEditor(payload); + } + + onMoveSelected = (payload) => { + this.props.dispatchExecuteCommand({ + name: commandNames.MOVE_SERIES, + ...payload + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesEditorConnector.propTypes = { + dispatchSetSeriesEditorSort: PropTypes.func.isRequired, + dispatchSetSeriesEditorFilter: PropTypes.func.isRequired, + dispatchSaveMovieEditor: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchExecuteCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesEditorConnector); diff --git a/frontend/src/Movie/Editor/SeriesEditorFilterModalConnector.js b/frontend/src/Movie/Editor/SeriesEditorFilterModalConnector.js new file mode 100644 index 000000000..ff9ab119e --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesEditorFilter } from 'Store/Actions/movieEditorActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movies.items, + (state) => state.moviesEditor.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'movieEditor' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setSeriesEditorFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Movie/Editor/SeriesEditorFooter.css b/frontend/src/Movie/Editor/SeriesEditorFooter.css new file mode 100644 index 000000000..815c700d3 --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorFooter.css @@ -0,0 +1,57 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.buttonContainer { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.buttonContainerContent { + flex-grow: 0; +} + +.buttons { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.organizeSelectedButton, +.tagsButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + margin-right: 10px; + height: 35px; +} + +.deleteSelectedButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + margin-left: 50px; + height: 35px; +} + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-right: 0; + } + + .buttonContainer { + justify-content: flex-start; + } + + .buttonContainerContent { + flex-grow: 1; + } + + .buttons { + justify-content: space-between; + } + + .selectedMovieLabel { + text-align: left; + } +} diff --git a/frontend/src/Movie/Editor/SeriesEditorFooter.js b/frontend/src/Movie/Editor/SeriesEditorFooter.js new file mode 100644 index 000000000..d49940213 --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorFooter.js @@ -0,0 +1,283 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector'; +import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import MoveSeriesModal from 'Movie/MoveSeries/MoveSeriesModal'; +import TagsModal from './Tags/TagsModal'; +import DeleteMovieModal from './Delete/DeleteMovieModal'; +import SeriesEditorFooterLabel from './SeriesEditorFooterLabel'; +import styles from './SeriesEditorFooter.css'; + +const NO_CHANGE = 'noChange'; + +class SeriesEditorFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false, + isDeleteMovieModalOpen: false, + isTagsModalOpen: false, + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + + if (value === NO_CHANGE) { + return; + } + + switch (name) { + case 'rootFolderPath': + this.setState({ + isConfirmMoveModalOpen: true, + destinationRootFolder: value + }); + break; + case 'monitored': + this.props.onSaveSelected({ [name]: value === 'monitored' }); + break; + default: + this.props.onSaveSelected({ [name]: value }); + } + } + + onApplyTagsPress = (tags, applyTags) => { + this.setState({ + savingTags: true, + isTagsModalOpen: false + }); + + this.props.onSaveSelected({ + tags, + applyTags + }); + } + + onDeleteSelectedPress = () => { + this.setState({ isDeleteMovieModalOpen: true }); + } + + onDeleteMovieModalClose = () => { + this.setState({ isDeleteMovieModalOpen: false }); + } + + onTagsPress = () => { + this.setState({ isTagsModalOpen: true }); + } + + onTagsModalClose = () => { + this.setState({ isTagsModalOpen: false }); + } + + onSaveRootFolderPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ rootFolderPath: this.state.destinationRootFolder }); + } + + onMoveSeriesPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ + rootFolderPath: this.state.destinationRootFolder, + moveFiles: true + }); + } + + // + // Render + + render() { + const { + seriesIds, + selectedCount, + isSaving, + isDeleting, + isOrganizingSeries, + onOrganizeSeriesPress + } = this.props; + + const { + monitored, + qualityProfileId, + rootFolderPath, + savingTags, + isTagsModalOpen, + isDeleteMovieModalOpen, + isConfirmMoveModalOpen, + destinationRootFolder + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + return ( + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+ + +
+
+ + Rename Files + + + + Set Tags + +
+ + + Delete + +
+
+
+ + + + + + +
+ ); + } +} + +SeriesEditorFooter.propTypes = { + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingSeries: PropTypes.bool.isRequired, + onSaveSelected: PropTypes.func.isRequired, + onOrganizeSeriesPress: PropTypes.func.isRequired +}; + +export default SeriesEditorFooter; diff --git a/frontend/src/Movie/Editor/SeriesEditorFooterLabel.css b/frontend/src/Movie/Editor/SeriesEditorFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Movie/Editor/SeriesEditorFooterLabel.js b/frontend/src/Movie/Editor/SeriesEditorFooterLabel.js new file mode 100644 index 000000000..fc77ece44 --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorFooterLabel.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import styles from './SeriesEditorFooterLabel.css'; + +function SeriesEditorFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
+ {label} + + { + isSaving && + + } +
+ ); +} + +SeriesEditorFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +SeriesEditorFooterLabel.defaultProps = { + className: styles.label +}; + +export default SeriesEditorFooterLabel; diff --git a/frontend/src/Movie/Editor/SeriesEditorRow.js b/frontend/src/Movie/Editor/SeriesEditorRow.js new file mode 100644 index 000000000..742e34925 --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorRow.js @@ -0,0 +1,97 @@ +// import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +// import titleCase from 'Utilities/String/titleCase'; +import TagListConnector from 'Components/TagListConnector'; +// import CheckInput from 'Components/Form/CheckInput'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import MovieStatusCell from 'Movie/Index/Table/MovieStatusCell'; + +class SeriesEditorRow extends Component { + + // + // Listeners + + onSeasonFolderChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + // + // Render + + render() { + const { + id, + status, + titleSlug, + title, + monitored, + qualityProfile, + path, + tags, + // columns, + isSelected, + onSelectedChange + } = this.props; + + return ( + + + + + + + + + + + {qualityProfile.name} + + + + {path} + + + + + + + ); + } +} + +SeriesEditorRow.propTypes = { + id: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +SeriesEditorRow.defaultProps = { + tags: [] +}; + +export default SeriesEditorRow; diff --git a/frontend/src/Movie/Editor/SeriesEditorRowConnector.js b/frontend/src/Movie/Editor/SeriesEditorRowConnector.js new file mode 100644 index 000000000..1b70e92fe --- /dev/null +++ b/frontend/src/Movie/Editor/SeriesEditorRowConnector.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; +import SeriesEditorRow from './SeriesEditorRow'; + +function createMapStateToProps() { + return createSelector( + createQualityProfileSelector(), + (qualityProfile) => { + return { + qualityProfile + }; + } + ); +} + +function SeriesEditorRowConnector(props) { + return ( + + ); +} + +SeriesEditorRowConnector.propTypes = { + qualityProfileId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps)(SeriesEditorRowConnector); diff --git a/frontend/src/Movie/Editor/Tags/TagsModal.js b/frontend/src/Movie/Editor/Tags/TagsModal.js new file mode 100644 index 000000000..0f6c2d7ec --- /dev/null +++ b/frontend/src/Movie/Editor/Tags/TagsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContentConnector from './TagsModalContentConnector'; + +function TagsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagsModal; diff --git a/frontend/src/Movie/Editor/Tags/TagsModalContent.css b/frontend/src/Movie/Editor/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Movie/Editor/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Movie/Editor/Tags/TagsModalContent.js b/frontend/src/Movie/Editor/Tags/TagsModalContent.js new file mode 100644 index 000000000..ccc1120db --- /dev/null +++ b/frontend/src/Movie/Editor/Tags/TagsModalContent.js @@ -0,0 +1,187 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './TagsModalContent.css'; + +class TagsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + tags: [], + applyTags: 'add' + }; + } + + // + // Lifecycle + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onApplyTagsPress = () => { + const { + tags, + applyTags + } = this.state; + + this.props.onApplyTagsPress(tags, applyTags); + } + + // + // Render + + render() { + const { + seriesTags, + tagList, + onModalClose + } = this.props; + + const { + tags, + applyTags + } = this.state; + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' } + ]; + + return ( + + + Tags + + + +
+ + Tags + + + + + + Apply Tags + + + + + + Result + +
+ { + seriesTags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + const removeTag = (applyTags === 'remove' && tags.indexOf(t) > -1) || + (applyTags === 'replace' && tags.indexOf(t) === -1); + + return ( + + ); + }) + } + + { + (applyTags === 'add' || applyTags === 'replace') && + tags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + if (seriesTags.indexOf(t) > -1) { + return null; + } + + return ( + + ); + }) + } +
+
+
+
+ + + + + + +
+ ); + } +} + +TagsModalContent.propTypes = { + seriesTags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onApplyTagsPress: PropTypes.func.isRequired +}; + +export default TagsModalContent; diff --git a/frontend/src/Movie/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Movie/Editor/Tags/TagsModalContentConnector.js new file mode 100644 index 000000000..50c780385 --- /dev/null +++ b/frontend/src/Movie/Editor/Tags/TagsModalContentConnector.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import TagsModalContent from './TagsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllMoviesSelector(), + createTagsSelector(), + (seriesIds, allMovies, tagList) => { + const series = _.intersectionWith(allMovies, seriesIds, (s, id) => { + return s.id === id; + }); + + const seriesTags = _.uniq(_.concat(..._.map(series, 'tags'))); + + return { + seriesTags, + tagList + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onAction() { + // Do something + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent); diff --git a/frontend/src/Movie/History/MovieHistoryModal.js b/frontend/src/Movie/History/MovieHistoryModal.js new file mode 100644 index 000000000..8f6d8c034 --- /dev/null +++ b/frontend/src/Movie/History/MovieHistoryModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import MovieHistoryModalContentConnector from './MovieHistoryModalContentConnector'; + +function MovieHistoryModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +MovieHistoryModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieHistoryModal; diff --git a/frontend/src/Movie/History/MovieHistoryModalContent.js b/frontend/src/Movie/History/MovieHistoryModalContent.js new file mode 100644 index 000000000..2635c6c12 --- /dev/null +++ b/frontend/src/Movie/History/MovieHistoryModalContent.js @@ -0,0 +1,136 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import MovieHistoryRowConnector from './MovieHistoryRowConnector'; +const columns = [ + { + name: 'eventType', + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isVisible: true + }, + { + name: 'details', + label: 'Details', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class MovieHistoryModalContent extends Component { + + // + // Render + + render() { + const { + seasonNumber, + isFetching, + isPopulated, + error, + items, + onMarkAsFailedPress, + onModalClose + } = this.props; + + const fullSeries = seasonNumber == null; + const hasItems = !!items.length; + + return ( + + + History + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load history.
+ } + + { + isPopulated && !hasItems && !error && +
No history.
+ } + + { + isPopulated && hasItems && !error && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ + + + +
+ ); + } +} + +MovieHistoryModalContent.propTypes = { + seasonNumber: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieHistoryModalContent; diff --git a/frontend/src/Movie/History/MovieHistoryModalContentConnector.js b/frontend/src/Movie/History/MovieHistoryModalContentConnector.js new file mode 100644 index 000000000..581069cd5 --- /dev/null +++ b/frontend/src/Movie/History/MovieHistoryModalContentConnector.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchMovieHistory, clearMovieHistory, seriesHistoryMarkAsFailed } from 'Store/Actions/movieHistoryActions'; +import MovieHistoryModalContent from './MovieHistoryModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.moviesHistory, + (seriesHistory) => { + return seriesHistory; + } + ); +} + +const mapDispatchToProps = { + fetchMovieHistory, + clearMovieHistory, + seriesHistoryMarkAsFailed +}; + +class MovieHistoryModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchMovieHistory({ + seriesId, + seasonNumber + }); + } + + componentWillUnmount() { + this.props.clearMovieHistory(); + } + + // + // Listeners + + onMarkAsFailedPress = (historyId) => { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.seriesHistoryMarkAsFailed({ + historyId, + seriesId, + seasonNumber + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MovieHistoryModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + fetchMovieHistory: PropTypes.func.isRequired, + clearMovieHistory: PropTypes.func.isRequired, + seriesHistoryMarkAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MovieHistoryModalContentConnector); diff --git a/frontend/src/Movie/History/MovieHistoryRow.css b/frontend/src/Movie/History/MovieHistoryRow.css new file mode 100644 index 000000000..8c3fb8272 --- /dev/null +++ b/frontend/src/Movie/History/MovieHistoryRow.css @@ -0,0 +1,6 @@ +.details, +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 65px; +} diff --git a/frontend/src/Movie/History/MovieHistoryRow.js b/frontend/src/Movie/History/MovieHistoryRow.js new file mode 100644 index 000000000..259052b1a --- /dev/null +++ b/frontend/src/Movie/History/MovieHistoryRow.js @@ -0,0 +1,156 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import MovieQuality from 'Movie/MovieQuality'; +import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; +import styles from './MovieHistoryRow.css'; + +function getTitle(eventType) { + switch (eventType) { + case 'grabbed': return 'Grabbed'; + case 'seriesFolderImported': return 'Series Folder Imported'; + case 'downloadFolderImported': return 'Download Folder Imported'; + case 'downloadFailed': return 'Download Failed'; + case 'episodeFileDeleted': return 'Episode File Deleted'; + case 'episodeFileRenamed': return 'Episode File Renamed'; + default: return 'Unknown'; + } +} + +class MovieHistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMarkAsFailedModalOpen: false + }; + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.setState({ isMarkAsFailedModalOpen: true }); + } + + onConfirmMarkAsFailed = () => { + this.props.onMarkAsFailedPress(this.props.id); + this.setState({ isMarkAsFailedModalOpen: false }); + } + + onMarkAsFailedModalClose = () => { + this.setState({ isMarkAsFailedModalOpen: false }); + } + + // + // Render + + render() { + const { + eventType, + sourceTitle, + quality, + qualityCutoffNotMet, + date, + data + // movie, + } = this.props; + + const { + isMarkAsFailedModalOpen + } = this.state; + + return ( + + + + + {sourceTitle} + + + + + + + + + + + } + title={getTitle(eventType)} + body={ + + } + position={tooltipPositions.LEFT} + /> + + + + { + eventType === 'grabbed' && + + } + + + + + ); + } +} + +MovieHistoryRow.propTypes = { + id: PropTypes.number.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + fullSeries: PropTypes.bool.isRequired, + movie: PropTypes.object.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default MovieHistoryRow; diff --git a/frontend/src/Movie/History/MovieHistoryRowConnector.js b/frontend/src/Movie/History/MovieHistoryRowConnector.js new file mode 100644 index 000000000..fbabc6321 --- /dev/null +++ b/frontend/src/Movie/History/MovieHistoryRowConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import MovieHistoryRow from './MovieHistoryRow'; + +function createMapStateToProps() { + return createSelector( + createMovieSelector(), + (movie) => { + return { + movie + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MovieHistoryRow); diff --git a/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js new file mode 100644 index 000000000..605cfd3f7 --- /dev/null +++ b/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import MovieIndexFilterModalConnector from 'Movie/Index/MovieIndexFilterModalConnector'; + +function MovieIndexFilterMenu(props) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect + } = props; + + return ( + + ); +} + +MovieIndexFilterMenu.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +MovieIndexFilterMenu.defaultProps = { + showCustomFilters: false +}; + +export default MovieIndexFilterMenu; diff --git a/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js new file mode 100644 index 000000000..8ffc0880a --- /dev/null +++ b/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, sortDirections } from 'Helpers/Props'; +import SortMenu from 'Components/Menu/SortMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import SortMenuItem from 'Components/Menu/SortMenuItem'; + +function MovieIndexSortMenu(props) { + const { + sortKey, + sortDirection, + isDisabled, + onSortSelect + } = props; + + return ( + + + + Monitored/Status + + + + Title + + + + Studio + + + + Quality Profile + + + + Added + + + + In Cinemas + + + + Physical Release + + + + Path + + + + ); +} + +MovieIndexSortMenu.propTypes = { + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + isDisabled: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired +}; + +export default MovieIndexSortMenu; diff --git a/frontend/src/Movie/Index/Menus/MovieIndexViewMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexViewMenu.js new file mode 100644 index 000000000..30cb6bd66 --- /dev/null +++ b/frontend/src/Movie/Index/Menus/MovieIndexViewMenu.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import ViewMenu from 'Components/Menu/ViewMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; + +function MovieIndexViewMenu(props) { + const { + view, + isDisabled, + onViewSelect + } = props; + + return ( + + + + Table + + + + Posters + + + + Overview + + + + ); +} + +MovieIndexViewMenu.propTypes = { + view: PropTypes.string.isRequired, + isDisabled: PropTypes.bool.isRequired, + onViewSelect: PropTypes.func.isRequired +}; + +export default MovieIndexViewMenu; diff --git a/frontend/src/Movie/Index/MovieIndex.css b/frontend/src/Movie/Index/MovieIndex.css new file mode 100644 index 000000000..443372a73 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndex.css @@ -0,0 +1,51 @@ +.pageContentBodyWrapper { + display: flex; + flex: 1 0 1px; + overflow: hidden; +} + +.contentBody { + composes: contentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; +} + +.postersInnerContentBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.tableInnerContentBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.contentBodyContainer { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +@media only screen and (max-width: $breakpointSmall) { + .pageContentBodyWrapper { + flex-basis: auto; + } + + .contentBody { + flex-basis: 1px; + } + + .postersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } +} diff --git a/frontend/src/Movie/Index/MovieIndex.js b/frontend/src/Movie/Index/MovieIndex.js new file mode 100644 index 000000000..00aad5099 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndex.js @@ -0,0 +1,369 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import NoMovie from 'Movie/NoMovie'; +import MovieIndexTableConnector from './Table/MovieIndexTableConnector'; +import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal'; +import MovieIndexPostersConnector from './Posters/MovieIndexPostersConnector'; +import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal'; +import MovieIndexOverviewsConnector from './Overview/MovieIndexOverviewsConnector'; +import MovieIndexFooter from './MovieIndexFooter'; +import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; +import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; +import MovieIndexViewMenu from './Menus/MovieIndexViewMenu'; +import styles from './MovieIndex.css'; + +function getViewComponent(view) { + if (view === 'posters') { + return MovieIndexPostersConnector; + } + + if (view === 'overview') { + return MovieIndexOverviewsConnector; + } + + return MovieIndexTableConnector; +} + +class MovieIndex extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + contentBody: null, + jumpBarItems: [], + jumpToCharacter: null, + isPosterOptionsModalOpen: false, + isOverviewOptionsModalOpen: false, + isRendered: false + }; + } + + componentDidMount() { + this.setJumpBarItems(); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection, + scrollTop + } = this.props; + + if ( + hasDifferentItems(prevProps.items, items) || + sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection + ) { + this.setJumpBarItems(); + } + + if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) { + this.setState({ jumpToCharacter: null }); + } + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + setJumpBarItems() { + const { + items, + sortKey, + sortDirection + } = this.props; + + // Reset if not sorting by sortTitle + if (sortKey !== 'sortTitle') { + this.setState({ jumpBarItems: [] }); + return; + } + + const characters = _.reduce(items, (acc, item) => { + const firstCharacter = item.sortTitle.charAt(0); + + if (isNaN(firstCharacter)) { + acc.push(firstCharacter); + } else { + acc.push('#'); + } + + return acc; + }, []).sort(); + + // Reverse if sorting descending + if (sortDirection === sortDirections.DESCENDING) { + characters.reverse(); + } + + this.setState({ jumpBarItems: _.sortedUniq(characters) }); + } + + // + // Listeners + + onPosterOptionsPress = () => { + this.setState({ isPosterOptionsModalOpen: true }); + } + + onPosterOptionsModalClose = () => { + this.setState({ isPosterOptionsModalOpen: false }); + } + + onOverviewOptionsPress = () => { + this.setState({ isOverviewOptionsModalOpen: true }); + } + + onOverviewOptionsModalClose = () => { + this.setState({ isOverviewOptionsModalOpen: false }); + } + + onJumpBarItemPress = (jumpToCharacter) => { + this.setState({ jumpToCharacter }); + } + + onRender = () => { + this.setState({ isRendered: true }, () => { + const { + scrollTop, + isSmallScreen + } = this.props; + + if (isSmallScreen) { + // Seems to result in the view being off by 125px (distance to the top of the page) + // document.documentElement.scrollTop = document.body.scrollTop = scrollTop; + + // This works, but then jumps another 1px after scrolling + document.documentElement.scrollTop = scrollTop; + } + }); + } + + onScroll = ({ scrollTop }) => { + this.props.onScroll({ scrollTop }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + isRefreshingMovie, + isRssSyncExecuting, + scrollTop, + onSortSelect, + onFilterSelect, + onViewSelect, + onRefreshMoviePress, + onRssSyncPress, + ...otherProps + } = this.props; + + const { + contentBody, + jumpBarItems, + jumpToCharacter, + isPosterOptionsModalOpen, + isOverviewOptionsModalOpen, + isRendered + } = this.state; + + const ViewComponent = getViewComponent(view); + const isLoaded = !!(!error && isPopulated && items.length && contentBody); + const hasNoMovie = !totalItems; + + return ( + + + + + + + + + + + + { + view === 'posters' && + + } + + { + view === 'overview' && + + } + + { + (view === 'posters' || view === 'overview') && + + } + + + + + + + + + +
+ + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load movies
+ } + + { + isLoaded && +
+ + + +
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + { + isLoaded && !!jumpBarItems.length && + + } +
+ + + + +
+ ); + } +} + +MovieIndex.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + view: PropTypes.string.isRequired, + isRefreshingMovie: PropTypes.bool.isRequired, + isRssSyncExecuting: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onViewSelect: PropTypes.func.isRequired, + onRefreshMoviePress: PropTypes.func.isRequired, + onRssSyncPress: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default MovieIndex; diff --git a/frontend/src/Movie/Index/MovieIndexConnector.js b/frontend/src/Movie/Index/MovieIndexConnector.js new file mode 100644 index 000000000..f420db396 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexConnector.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { fetchMovies } from 'Store/Actions/movieActions'; +import scrollPositions from 'Store/scrollPositions'; +import { setMovieSort, setMovieFilter, setMovieView } from 'Store/Actions/movieIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import withScrollPosition from 'Components/withScrollPosition'; +import MovieIndex from './MovieIndex'; + +const POSTERS_PADDING = 15; +const POSTERS_PADDING_SMALL_SCREEN = 5; +const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding); +const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen); + +// If the scrollTop is greater than zero it needs to be offset +// by the padding so when it is set initially so it is correct +// after React Virtualized takes the padding into account. + +function getScrollTop(view, scrollTop, isSmallScreen) { + if (scrollTop === 0) { + return 0; + } + + let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING; + + if (view === 'posters') { + padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING; + } + + return scrollTop + padding; +} + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('movies', 'movieIndex'), + createCommandExecutingSelector(commandNames.REFRESH_MOVIE), + createCommandExecutingSelector(commandNames.RSS_SYNC), + createDimensionsSelector(), + ( + series, + isRefreshingMovie, + isRssSyncExecuting, + dimensionsState + ) => { + return { + ...series, + isRefreshingMovie, + isRssSyncExecuting, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + fetchMovies, + setMovieSort, + setMovieFilter, + setMovieView, + executeCommand +}; + +class MovieIndexConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + view, + scrollTop, + isSmallScreen + } = props; + + this.state = { + scrollTop: getScrollTop(view, scrollTop, isSmallScreen) + }; + } + + componentDidMount() { + this.props.fetchMovies(); + } + + // + // Listeners + + onSortSelect = (sortKey) => { + this.props.setMovieSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setMovieFilter({ selectedFilterKey }); + } + + onViewSelect = (view) => { + // Reset the scroll position before changing the view + this.setState({ scrollTop: 0 }, () => { + this.props.setMovieView({ view }); + }); + } + + onScroll = ({ scrollTop }) => { + this.setState({ + scrollTop + }, () => { + scrollPositions.movieIndex = scrollTop; + }); + } + + onRefreshMoviePress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_MOVIE + }); + } + + onRssSyncPress = () => { + this.props.executeCommand({ + name: commandNames.RSS_SYNC + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MovieIndexConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + view: PropTypes.string.isRequired, + scrollTop: PropTypes.number.isRequired, + fetchMovies: PropTypes.func.isRequired, + setMovieSort: PropTypes.func.isRequired, + setMovieFilter: PropTypes.func.isRequired, + setMovieView: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withScrollPosition( + connect(createMapStateToProps, mapDispatchToProps)(MovieIndexConnector), + 'movieIndex' +); diff --git a/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js b/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js new file mode 100644 index 000000000..c3994ba60 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setMovieFilter } from 'Store/Actions/movieIndexActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movies.items, + (state) => state.movieIndex.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'movieIndex' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setMovieFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Movie/Index/MovieIndexFooter.css b/frontend/src/Movie/Index/MovieIndexFooter.css new file mode 100644 index 000000000..ef0a818bf --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexFooter.css @@ -0,0 +1,72 @@ +.footer { + display: flex; + flex-wrap: wrap; + margin-top: 20px; + font-size: $smallFontSize; +} + +.legendItem { + display: flex; + margin-bottom: 4px; + line-height: 16px; +} + +.legendItemColor { + margin-right: 8px; + width: 30px; + height: 16px; + border-radius: 4px; +} + +.continuing { + composes: legendItemColor; + + background-color: $primaryColor; +} + +.availNotMonitored { + composes: legendItemColor; + + background-color: $darkGray; +} + +.ended { + composes: legendItemColor; + + background-color: $successColor; +} + +.missingMonitored { + composes: legendItemColor; + + background-color: $dangerColor; +} + +.missingUnmonitored { + composes: legendItemColor; + + background-color: $warningColor; +} + +.statistics { + display: flex; + justify-content: space-between; + flex-wrap: wrap; +} + +@media (max-width: $breakpointLarge) { + .statistics { + display: block; + } +} + +@media (max-width: $breakpointSmall) { + .footer { + display: block; + } + + .statistics { + display: flex; + margin-top: 20px; + } +} diff --git a/frontend/src/Movie/Index/MovieIndexFooter.js b/frontend/src/Movie/Index/MovieIndexFooter.js new file mode 100644 index 000000000..4e5ba6197 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexFooter.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './MovieIndexFooter.css'; + +class MovieIndexFooter extends PureComponent { + + render() { + const { + movies + } = this.props; + + const count = movies.length; + let movieFiles = 0; + let monitored = 0; + let totalFileSize = 0; + + movies.forEach((s) => { + const { statistics = {} } = s; + + const { + sizeOnDisk = 0 + } = statistics; + + if (s.hasFile) { + movieFiles += 1; + } + + // if (s.status === 'ended') { + // ended++; + // } else { + // continuing++; + // } + + if (s.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( +
+
+
+
+
Downloaded and Monitored
+
+ +
+
+
Downloaded, but not Monitored
+
+ +
+
+
Missing, but not Monitored
+
+ +
+
+
Missing, Monitored and considered Available
+
+ +
+
+
Unreleased
+
+
+ +
+ + + + + + + + + + + + + + + +
+
+ ); + } +} + +MovieIndexFooter.propTypes = { + movies: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default MovieIndexFooter; diff --git a/frontend/src/Movie/Index/MovieIndexItemConnector.js b/frontend/src/Movie/Index/MovieIndexItemConnector.js new file mode 100644 index 000000000..422980751 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexItemConnector.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; + +function selectShowSearchAction() { + return createSelector( + (state) => state.movieIndex, + (movieIndex) => { + const view = movieIndex.view; + + switch (view) { + case 'posters': + return movieIndex.posterOptions.showSearchAction; + case 'overview': + return movieIndex.overviewOptions.showSearchAction; + default: + return movieIndex.tableOptions.showSearchAction; + } + } + ); +} + +function createMapStateToProps() { + return createSelector( + createMovieSelector(), + createQualityProfileSelector(), + selectShowSearchAction(), + createCommandsSelector(), + ( + movie, + qualityProfile, + showSearchAction, + commands + ) => { + const isRefreshingMovie = commands.some((command) => { + return ( + command.name === commandNames.REFRESH_MOVIE && + command.body.movieId === movie.id && + isCommandExecuting(command) + ); + }); + + const isSearchingMovie = commands.some((command) => { + return ( + command.name === commandNames.MOVIE_SEARCH && + command.body.movieId === movie.id && + isCommandExecuting(command) + ); + }); + + return { + ...movie, + qualityProfile, + showSearchAction, + isRefreshingMovie, + isSearchingMovie + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class MovieIndexItemConnector extends Component { + + // + // Listeners + + onRefreshMoviePress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_MOVIE, + movieId: this.props.id + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.MOVIE_SEARCH, + movieIds: [this.props.id] + }); + } + + // + // Render + + render() { + const { + component: ItemComponent, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +MovieIndexItemConnector.propTypes = { + id: PropTypes.number.isRequired, + component: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MovieIndexItemConnector); diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverview.css b/frontend/src/Movie/Index/Overview/MovieIndexOverview.css new file mode 100644 index 000000000..6311d9be1 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverview.css @@ -0,0 +1,96 @@ +$hoverScale: 1.05; + +.container { + &:hover { + .content { + background-color: $tableRowHoverBackgroundColor; + } + } +} + +.content { + display: flex; + flex-grow: 1; +} + +.poster { + position: relative; +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + display: block; + color: $defaultColor; + + &:hover { + color: $defaultColor; + text-decoration: none; + } +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.info { + display: flex; + flex: 1 0 1px; + flex-direction: column; + overflow: hidden; + padding-left: 10px; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + margin-bottom: 10px; + line-height: 32px; +} + +.title { + @add-mixin truncate; + composes: link; + + flex: 1 0 1px; + font-weight: 300; + font-size: 30px; +} + +.actions { + white-space: nowrap; +} + +.details { + display: flex; + justify-content: space-between; + flex: 1 0 auto; +} + +.overview { + composes: link; + + flex: 0 1 1000px; + overflow: hidden; + min-height: 0; +} + +@media only screen and (max-width: $breakpointSmall) { + .overview { + display: none; + } +} diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverview.js b/frontend/src/Movie/Index/Overview/MovieIndexOverview.js new file mode 100644 index 000000000..95f574bec --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverview.js @@ -0,0 +1,258 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import MoviePoster from 'Movie/MoviePoster'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; +import MovieIndexOverviewInfo from './MovieIndexOverviewInfo'; +import styles from './MovieIndexOverview.css'; + +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height beased on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const titleRowHeight = 42; + +function getContentHeight(rowHeight, isSmallScreen) { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; +} + +class MovieIndexOverview extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: false + }; + } + + // + // Listeners + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onDeleteMoviePress = () => { + this.setState({ + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: true + }); + } + + onDeleteMovieModalClose = () => { + this.setState({ isDeleteMovieModalOpen: false }); + } + + // + // Render + + render() { + const { + style, + id, + title, + overview, + monitored, + hasFile, + status, + titleSlug, + images, + posterWidth, + posterHeight, + qualityProfile, + overviewOptions, + showSearchAction, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + rowHeight, + isSmallScreen, + isRefreshingMovie, + isSearchingMovie, + onRefreshMoviePress, + onSearchPress, + ...otherProps + } = this.props; + + const { + isEditMovieModalOpen, + isDeleteMovieModalOpen + } = this.state; + + const link = `/movie/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + const contentHeight = getContentHeight(rowHeight, isSmallScreen); + const overviewHeight = contentHeight - titleRowHeight; + + return ( +
+
+
+
+ { + status === 'ended' && +
+ } + + + + +
+ + +
+ +
+
+ + {title} + + +
+ + + { + showSearchAction && + + } + + +
+
+ +
+ + + + + +
+
+
+ + + + +
+ ); + } +} + +MovieIndexOverview.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + rowHeight: PropTypes.number.isRequired, + qualityProfile: PropTypes.object.isRequired, + overviewOptions: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isRefreshingMovie: PropTypes.bool.isRequired, + isSearchingMovie: PropTypes.bool.isRequired, + onRefreshMoviePress: PropTypes.func.isRequired +}; + +export default MovieIndexOverview; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.css b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.css new file mode 100644 index 000000000..5dc53762f --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.css @@ -0,0 +1,12 @@ +.infos { + display: flex; + flex: 0 0 250px; + flex-direction: column; + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .infos { + margin-left: 0; + } +} diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js new file mode 100644 index 000000000..f69c928ee --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js @@ -0,0 +1,192 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import MovieIndexOverviewInfoRow from './MovieIndexOverviewInfoRow'; +import styles from './MovieIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.movieIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored' + + }, + { + name: 'studio', + showProp: 'showStudio', + valueProp: 'studio' + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfileId' + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added' + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path' + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk' + } +]; + +function isVisible(row, props) { + const { + name, + showProp, + valueProp + } = row; + + if (props[valueProp] == null) { + return false; + } + + return props[showProp] || props.sortKey === name; +} + +function getInfoRowProps(row, props) { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText + }; + } + + if (name === 'studio') { + return { + title: 'Studio', + iconName: icons.STUDIO, + label: props.studio + }; + } + + if (name === 'qualityProfileId') { + return { + title: 'Quality Profile', + iconName: icons.PROFILE, + label: props.qualityProfile.name + }; + } + + if (name === 'added') { + const { + added, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + }; + } + + if (name === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.path + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk) + }; + } +} + +function MovieIndexOverviewInfo(props) { + const { + height + // showRelativeDates, + // shortDateFormat, + // longDateFormat, + // timeFormat + } = props; + + let shownRows = 1; + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + return ( +
+ { + rows.map((row) => { + if (!isVisible(row, props)) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props); + + return ( + + ); + }) + } +
+ ); +} + +MovieIndexOverviewInfo.propTypes = { + height: PropTypes.number.isRequired, + showStudio: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + monitored: PropTypes.bool.isRequired, + studio: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + added: PropTypes.string, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default MovieIndexOverviewInfo; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.css b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.css new file mode 100644 index 000000000..ea94e2ef6 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.css @@ -0,0 +1,10 @@ +.infoRow { + flex: 0 0 $movieIndexOverviewInfoRowHeight; + margin: 2px 0; +} + +.icon { + margin-right: 5px; + width: 25px !important; + text-align: center; +} diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js new file mode 100644 index 000000000..15e77426d --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './MovieIndexOverviewInfoRow.css'; + +function MovieIndexOverviewInfoRow(props) { + const { + title, + iconName, + label + } = props; + + return ( +
+ + + {label} +
+ ); +} + +MovieIndexOverviewInfoRow.propTypes = { + title: PropTypes.string, + iconName: PropTypes.object.isRequired, + label: PropTypes.string.isRequired +}; + +export default MovieIndexOverviewInfoRow; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviews.css b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js new file mode 100644 index 000000000..71040148f --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js @@ -0,0 +1,288 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector'; +import MovieIndexOverview from './MovieIndexOverview'; +import styles from './MovieIndexOverviews.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +function calculatePosterWidth(posterSize, isSmallScreen) { + const maxiumPosterWidth = isSmallScreen ? 152 : 162; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { + const { + detailedProgressBar + } = overviewOptions; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class MovieIndexOverviews extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + overviewOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + const overviewOptionsChanged = !_.isMatch(prevProps.overviewOptions, overviewOptions); + + if ( + prevProps.sortKey !== sortKey || + prevProps.overviewOptions !== overviewOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged || + overviewOptionsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + rowHeight + } = this.state; + + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + scrollToFirstCharacter(character) { + const items = this.props.items; + const { + rowHeight + } = this.state; + + const index = getIndexOfFirstCharacter(items, character); + + if (index != null) { + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + overviewOptions + } = this.props; + + const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); + + this.setState({ + width, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, style }) => { + const { + items, + sortKey, + overviewOptions, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + isSmallScreen + } = this.props; + + const { + posterWidth, + posterHeight, + rowHeight + } = this.state; + + const movie = items[rowIndex]; + + if (!movie) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + rowHeight + } = this.state; + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +MovieIndexOverviews.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + overviewOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default MovieIndexOverviews; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js new file mode 100644 index 000000000..5cef44c78 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import MovieIndexOverviews from './MovieIndexOverviews'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieIndex.overviewOptions, + createClientSideCollectionSelector('movies', 'movieIndex'), + createUISettingsSelector(), + createDimensionsSelector(), + (overviewOptions, movies, uiSettings, dimensions) => { + return { + overviewOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen, + ...movies + }; + } + ); +} + +export default connect(createMapStateToProps)(MovieIndexOverviews); diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js new file mode 100644 index 000000000..75b38e228 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import MovieIndexOverviewOptionsModalContentConnector from './MovieIndexOverviewOptionsModalContentConnector'; + +function MovieIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +MovieIndexOverviewOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieIndexOverviewOptionsModal; diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js new file mode 100644 index 000000000..01a682892 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js @@ -0,0 +1,267 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class MovieIndexOverviewOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showMonitored: props.showMonitored, + showStudio: props.showStudio, + showQualityProfile: props.showQualityProfile, + showAdded: props.showAdded, + showPath: props.showPath, + showSizeOnDisk: props.showSizeOnDisk, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showMonitored, + showStudio, + showQualityProfile, + showAdded, + showPath, + showSizeOnDisk, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showStudio !== prevProps.showStudio) { + state.showStudio = showStudio; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showAdded !== prevProps.showAdded) { + state.showAdded = showAdded; + } + + if (showPath !== prevProps.showPath) { + state.showPath = showPath; + } + + if (showSizeOnDisk !== prevProps.showSizeOnDisk) { + state.showSizeOnDisk = showSizeOnDisk; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangeOverviewOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangeOverviewOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showMonitored, + showStudio, + showQualityProfile, + showAdded, + showPath, + showSizeOnDisk, + showSearchAction + } = this.state; + + return ( + + + Overview Options + + + +
+ + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Monitored + + + + + + Show Studio + + + + + + Show Quality Profile + + + + + + Show Date Added + + + + + + Show Path + + + + + + Show Size on Disk + + + + + + Show Search + + + +
+
+ + + + +
+ ); + } +} + +MovieIndexOverviewOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showStudio: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangeOverviewOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieIndexOverviewOptionsModalContent; diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js new file mode 100644 index 000000000..06e3d7eb8 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setMovieOverviewOption } from 'Store/Actions/movieIndexActions'; +import MovieIndexOverviewOptionsModalContent from './MovieIndexOverviewOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieIndex, + (movieIndex) => { + return movieIndex.overviewOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangeOverviewOption(payload) { + dispatch(setMovieOverviewOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexOverviewOptionsModalContent); diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.css b/frontend/src/Movie/Index/Posters/MovieIndexPoster.css new file mode 100644 index 000000000..456ebf774 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPoster.css @@ -0,0 +1,100 @@ +$hoverScale: 1.05; + +.container { + padding: 10px; +} + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: block; + background-color: $defaultColor; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.nextAiring { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.title { + @add-mixin truncate; + + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.ended { + position: absolute; + top: 0; + right: 0; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #4f566f; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from 'Components/Link/IconButton.css'; + + &:hover { + color: $radarrYellow; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.js b/frontend/src/Movie/Index/Posters/MovieIndexPoster.js new file mode 100644 index 000000000..64d70adf2 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPoster.js @@ -0,0 +1,264 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import MoviePoster from 'Movie/MoviePoster'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; +import MovieIndexPosterInfo from './MovieIndexPosterInfo'; +import styles from './MovieIndexPoster.css'; + +class MovieIndexPoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: false + }; + } + + // + // Listeners + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onDeleteMoviePress = () => { + this.setState({ + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: true + }); + } + + onDeleteMovieModalClose = () => { + this.setState({ isDeleteMovieModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + style, + id, + title, + monitored, + hasFile, + status, + titleSlug, + images, + posterWidth, + posterHeight, + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + qualityProfile, + showSearchAction, + showRelativeDates, + shortDateFormat, + timeFormat, + isRefreshingMovie, + isSearchingMovie, + onRefreshMoviePress, + onSearchPress, + ...otherProps + } = this.props; + + const { + hasPosterError, + isEditMovieModalOpen, + isDeleteMovieModalOpen + } = this.state; + + const link = `/movie/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + return ( +
+
+
+ + + { + status === 'ended' && +
+ } + + + + + { + hasPosterError && +
+ {title} +
+ } + +
+ + + + { + showTitle && +
+ {title} +
+ } + + { + showMonitored && +
+ {monitored ? 'Monitored' : 'Unmonitored'} +
+ } + + { + showQualityProfile && +
+ {qualityProfile.name} +
+ } + + + + + + +
+
+ ); + } +} + +MovieIndexPoster.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isRefreshingMovie: PropTypes.bool.isRequired, + isSearchingMovie: PropTypes.bool.isRequired, + onRefreshMoviePress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +MovieIndexPoster.defaultProps = { + statistics: { + seasonCount: 0, + episodeCount: 0, + episodeFileCount: 0, + totalEpisodeCount: 0 + } +}; + +export default MovieIndexPoster; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.css b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.css new file mode 100644 index 000000000..aab27d827 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.css @@ -0,0 +1,5 @@ +.info { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js new file mode 100644 index 000000000..563061add --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import styles from './MovieIndexPosterInfo.css'; + +function MovieIndexPosterInfo(props) { + const { + studio, + qualityProfile, + showQualityProfile, + added, + path, + sizeOnDisk, + sortKey, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (sortKey === 'studio' && studio) { + return ( +
+ {studio} +
+ ); + } + + if (sortKey === 'qualityProfileId' && !showQualityProfile) { + return ( +
+ {qualityProfile.name} +
+ ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false + } + ); + + return ( +
+ {`Added ${addedDate}`} +
+ ); + } + + if (sortKey === 'path') { + return ( +
+ {path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + return null; +} + +MovieIndexPosterInfo.propTypes = { + studio: PropTypes.string, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + added: PropTypes.string, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default MovieIndexPosterInfo; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.css b/frontend/src/Movie/Index/Posters/MovieIndexPosters.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPosters.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.js b/frontend/src/Movie/Index/Posters/MovieIndexPosters.js new file mode 100644 index 000000000..101e13aa1 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPosters.js @@ -0,0 +1,324 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector'; +import MovieIndexPoster from './MovieIndexPoster'; +import styles from './MovieIndexPosters.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const additionalColumnCount = { + small: 3, + medium: 2, + large: 1 +}; + +function calculateColumnWidth(width, posterSize, isSmallScreen) { + const maxiumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maxiumColumnWidth); + const remainder = width % maxiumColumnWidth; + + if (remainder === 0 && posterSize === 'large') { + return maxiumColumnWidth; + } + + return Math.floor(width / (columns + additionalColumnCount[posterSize])); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + switch (sortKey) { + case 'studio': + case 'added': + case 'path': + case 'sizeOnDisk': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class MovieIndexPosters extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 182, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + posterOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + + if ( + prevProps.sortKey !== sortKey || + prevProps.posterOptions !== posterOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + columnCount, + rowHeight + } = this.state; + + const row = Math.floor(index / columnCount); + const scrollTop = rowHeight * row; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + posterOptions + } = this.props; + + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); + const columnCount = Math.max(Math.floor(width / columnWidth), 1); + const posterWidth = columnWidth - padding; + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); + + this.setState({ + width, + columnWidth, + columnCount, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, columnIndex, style }) => { + const { + items, + sortKey, + posterOptions, + showRelativeDates, + shortDateFormat, + timeFormat + } = this.props; + + const { + posterWidth, + posterHeight, + columnCount + } = this.state; + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const movie = items[rowIndex * columnCount + columnIndex]; + + if (!movie) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + const rowCount = Math.ceil(items.length / columnCount); + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +MovieIndexPosters.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + posterOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default MovieIndexPosters; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js b/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js new file mode 100644 index 000000000..222dc359a --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import MovieIndexPosters from './MovieIndexPosters'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieIndex.posterOptions, + createClientSideCollectionSelector('movies', 'movieIndex'), + createUISettingsSelector(), + createDimensionsSelector(), + (posterOptions, movies, uiSettings, dimensions) => { + return { + posterOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen, + ...movies + }; + } + ); +} + +export default connect(createMapStateToProps)(MovieIndexPosters); diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js new file mode 100644 index 000000000..a9aeaaed7 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import MovieIndexPosterOptionsModalContentConnector from './MovieIndexPosterOptionsModalContentConnector'; + +function MovieIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +MovieIndexPosterOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieIndexPosterOptionsModal; diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js new file mode 100644 index 000000000..9aa9d5160 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js @@ -0,0 +1,213 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class MovieIndexPosterOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showTitle: props.showTitle, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showTitle !== prevProps.showTitle) { + state.showTitle = showTitle; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangePosterOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangePosterOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.state; + + return ( + + + Poster Options + + + +
+ + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Title + + + + + + Show Monitored + + + + + + Show Quality Profile + + + + + + Show Search + + + +
+
+ + + + +
+ ); + } +} + +MovieIndexPosterOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangePosterOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieIndexPosterOptionsModalContent; diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js new file mode 100644 index 000000000..c8b9a2a88 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setMoviePosterOption } from 'Store/Actions/movieIndexActions'; +import MovieIndexPosterOptionsModalContent from './MovieIndexPosterOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieIndex, + (movieIndex) => { + return movieIndex.posterOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangePosterOption(payload) { + dispatch(setMoviePosterOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexPosterOptionsModalContent); diff --git a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css new file mode 100644 index 000000000..dbf3499ab --- /dev/null +++ b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css @@ -0,0 +1,14 @@ +.progress { + composes: container from 'Components/ProgressBar.css'; + + border-radius: 0; + background-color: #5b5b5b; + color: $white; + transition: width 200ms ease; +} + +.progressBar { + composes: progressBar from 'Components/ProgressBar.css'; + + transition: width 200ms ease; +} diff --git a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js new file mode 100644 index 000000000..babeece19 --- /dev/null +++ b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getProgressBarKind from 'Utilities/Movie/getProgressBarKind'; +import { sizes } from 'Helpers/Props'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './MovieIndexProgressBar.css'; + +function MovieIndexProgressBar(props) { + const { + monitored, + status, + hasFile, + posterWidth, + detailedProgressBar + } = props; + + const progress = 100; + + return ( + + ); +} + +MovieIndexProgressBar.propTypes = { + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + posterWidth: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired +}; + +export default MovieIndexProgressBar; diff --git a/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js b/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js new file mode 100644 index 000000000..92cead8a0 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; + +class MovieIndexActionsCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: false + }; + } + + // + // Listeners + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onDeleteMoviePress = () => { + this.setState({ + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: true + }); + } + + onDeleteMovieModalClose = () => { + this.setState({ isDeleteMovieModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + isRefreshingMovie, + onRefreshMoviePress, + ...otherProps + } = this.props; + + const { + isEditMovieModalOpen, + isDeleteMovieModalOpen + } = this.state; + + return ( + + + + + + + + + + ); + } +} + +MovieIndexActionsCell.propTypes = { + id: PropTypes.number.isRequired, + isRefreshingMovie: PropTypes.bool.isRequired, + onRefreshMoviePress: PropTypes.func.isRequired +}; + +export default MovieIndexActionsCell; diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeader.css b/frontend/src/Movie/Index/Table/MovieIndexHeader.css new file mode 100644 index 000000000..d0a559a07 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexHeader.css @@ -0,0 +1,67 @@ +.status { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 60px; +} + +.sortTitle { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 110px; +} + +.studio { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 2 0 90px; +} + +.qualityProfileId { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 125px; +} + +.added, +.inCinemas, +.genres { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 180px; +} + +.certification { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 100px; +} + +.path { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 60px; +} + +.actions { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeader.js b/frontend/src/Movie/Index/Table/MovieIndexHeader.js new file mode 100644 index 000000000..32df50861 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexHeader.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import MovieIndexTableOptionsConnector from './MovieIndexTableOptionsConnector'; +import styles from './MovieIndexHeader.css'; + +class MovieIndexHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + showSearchAction, + columns, + onTableOptionChange, + ...otherProps + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + ); + } + + return ( + + {label} + + ); + }) + } + + + + ); + } +} + +MovieIndexHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default MovieIndexHeader; diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js b/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js new file mode 100644 index 000000000..d56915879 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { setMovieTableOption } from 'Store/Actions/movieIndexActions'; +import MovieIndexHeader from './MovieIndexHeader'; + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setMovieTableOption(payload)); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(MovieIndexHeader); diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.css b/frontend/src/Movie/Index/Table/MovieIndexRow.css new file mode 100644 index 000000000..b7702acd3 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexRow.css @@ -0,0 +1,73 @@ +.status { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 60px; +} + +.sortTitle { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 4 0 110px; +} + +.studio { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 2 0 90px; +} + +.qualityProfileId { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 1 0 125px; +} + +.added, +.inCinemas, +.genres { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 180px; +} + +.certification { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 100px; +} + +.path { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 1 0 60px; +} + +.actions { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 90px; +} + +.checkInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 0; +} diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.js b/frontend/src/Movie/Index/Table/MovieIndexRow.js new file mode 100644 index 000000000..715ae88e1 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexRow.js @@ -0,0 +1,333 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +// import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; +import { icons } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +// import ProgressBar from 'Components/ProgressBar'; +import TagListConnector from 'Components/TagListConnector'; +// import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import MovieStatusCell from './MovieStatusCell'; +import styles from './MovieIndexRow.css'; + +class MovieIndexRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: false + }; + } + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onDeleteMoviePress = () => { + this.setState({ + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: true + }); + } + + onDeleteMovieModalClose = () => { + this.setState({ isDeleteMovieModalOpen: false }); + } + + onUseSceneNumberingChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + // + // Render + + render() { + const { + style, + id, + monitored, + status, + title, + titleSlug, + studio, + qualityProfile, + added, + inCinemas, + physicalRelease, + path, + genres, + ratings, + certification, + tags, + showSearchAction, + columns, + isRefreshingMovie, + isSearchingMovie, + onRefreshMoviePress, + onSearchPress + } = this.props; + + const { + isEditMovieModalOpen, + isDeleteMovieModalOpen + } = this.state; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortTitle') { + return ( + + + + ); + } + + if (name === 'studio') { + return ( + + {studio} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'inCinemas') { + return ( + + ); + } + + if (name === 'physicalRelease') { + return ( + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + + {joinedGenres} + + + ); + } + + if (name === 'ratings') { + return ( + + + + ); + } + + if (name === 'certification') { + return ( + + {certification} + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + { + showSearchAction && + + } + + + + ); + } + + return null; + }) + } + + + + + + ); + } +} + +MovieIndexRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + studio: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + added: PropTypes.string, + inCinemas: PropTypes.string, + physicalRelease: PropTypes.string, + path: PropTypes.string.isRequired, + genres: PropTypes.arrayOf(PropTypes.string).isRequired, + ratings: PropTypes.object.isRequired, + certification: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + showSearchAction: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingMovie: PropTypes.bool.isRequired, + isSearchingMovie: PropTypes.bool.isRequired, + onRefreshMoviePress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +MovieIndexRow.defaultProps = { + genres: [], + tags: [] +}; + +export default MovieIndexRow; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.css b/frontend/src/Movie/Index/Table/MovieIndexTable.css new file mode 100644 index 000000000..e46160a96 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTable.css @@ -0,0 +1,5 @@ +.tableContainer { + composes: tableContainer from 'Components/Table/VirtualTable.css'; + + flex: 1 0 auto; +} diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.js b/frontend/src/Movie/Index/Table/MovieIndexTable.js new file mode 100644 index 000000000..da5260d9b --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTable.js @@ -0,0 +1,126 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import { sortDirections } from 'Helpers/Props'; +import VirtualTable from 'Components/Table/VirtualTable'; +import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector'; +import MovieIndexHeaderConnector from './MovieIndexHeaderConnector'; +import MovieIndexRow from './MovieIndexRow'; +import styles from './MovieIndexTable.css'; + +class MovieIndexTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scrollIndex: null + }; + } + + componentDidUpdate(prevProps) { + const jumpToCharacter = this.props.jumpToCharacter; + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const items = this.props.items; + + const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (scrollIndex != null) { + this.setState({ scrollIndex }); + } + } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { + this.setState({ scrollIndex: null }); + } + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + items, + columns + } = this.props; + + const movie = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + columns, + filters, + sortKey, + sortDirection, + isSmallScreen, + scrollTop, + contentBody, + onSortPress, + onRender, + onScroll + } = this.props; + + return ( + + } + columns={columns} + filters={filters} + sortKey={sortKey} + sortDirection={sortDirection} + onRender={onRender} + onScroll={onScroll} + /> + ); + } +} + +MovieIndexTable.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default MovieIndexTable; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js b/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js new file mode 100644 index 000000000..52ff38ca3 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { setMovieSort } from 'Store/Actions/movieIndexActions'; +import MovieIndexTable from './MovieIndexTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.dimensions, + createClientSideCollectionSelector('movies', 'movieIndex'), + (dimensions, movies) => { + return { + isSmallScreen: dimensions.isSmallScreen, + ...movies + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSortPress(sortKey) { + dispatch(setMovieSort({ sortKey })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexTable); diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js b/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js new file mode 100644 index 000000000..169d390f3 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class MovieIndexTableOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { showSearchAction } = this.props; + + if (showSearchAction !== prevProps.showSearchAction) { + this.setState({ + showSearchAction + }); + } + } + + // + // Listeners + + onTableOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onTableOptionChange({ + tableOptions: { + ...this.state, + [name]: value + } + }); + }); + } + + // + // Render + + render() { + const { + showSearchAction + } = this.state; + + return ( + + Show Search + + + + ); + } +} + +MovieIndexTableOptions.propTypes = { + showSearchAction: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default MovieIndexTableOptions; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js b/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js new file mode 100644 index 000000000..018a98678 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import MovieIndexTableOptions from './MovieIndexTableOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieIndex.tableOptions, + (tableOptions) => { + return tableOptions; + } + ); +} + +export default connect(createMapStateToProps)(MovieIndexTableOptions); diff --git a/frontend/src/Movie/Index/Table/MovieStatusCell.css b/frontend/src/Movie/Index/Table/MovieStatusCell.css new file mode 100644 index 000000000..a7681e36c --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieStatusCell.css @@ -0,0 +1,9 @@ +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 60px; +} + +.statusIcon { + width: 20px !important; +} diff --git a/frontend/src/Movie/Index/Table/MovieStatusCell.js b/frontend/src/Movie/Index/Table/MovieStatusCell.js new file mode 100644 index 000000000..a3875dc1c --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieStatusCell.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './MovieStatusCell.css'; + +function MovieStatusCell(props) { + const { + className, + monitored, + status, + component: Component, + ...otherProps + } = props; + + return ( + + + + + + ); +} + +MovieStatusCell.propTypes = { + className: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + component: PropTypes.func +}; + +MovieStatusCell.defaultProps = { + className: styles.status, + component: VirtualTableRowCell +}; + +export default MovieStatusCell; diff --git a/frontend/src/Movie/MoveSeries/MoveSeriesModal.css b/frontend/src/Movie/MoveSeries/MoveSeriesModal.css new file mode 100644 index 000000000..11f33bef2 --- /dev/null +++ b/frontend/src/Movie/MoveSeries/MoveSeriesModal.css @@ -0,0 +1,5 @@ +.doNotMoveButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Movie/MoveSeries/MoveSeriesModal.js b/frontend/src/Movie/MoveSeries/MoveSeriesModal.js new file mode 100644 index 000000000..6d767f22f --- /dev/null +++ b/frontend/src/Movie/MoveSeries/MoveSeriesModal.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './MoveSeriesModal.css'; + +function MoveSeriesModal(props) { + const { + originalPath, + destinationPath, + destinationRootFolder, + isOpen, + onSavePress, + onMoveSeriesPress + } = props; + + if ( + isOpen && + !originalPath && + !destinationPath && + !destinationRootFolder + ) { + console.error('orginalPath and destinationPath OR destinationRootFolder must be provided'); + } + + return ( + + + + Move Files + + + + { + destinationRootFolder ? + `Would you like to move the series folders to '${destinationRootFolder}'?` : + `Would you like to move the series files from '${originalPath}' to '${destinationPath}'?` + } + + + + + + + + + + ); +} + +MoveSeriesModal.propTypes = { + originalPath: PropTypes.string, + destinationPath: PropTypes.string, + destinationRootFolder: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onMoveSeriesPress: PropTypes.func.isRequired +}; + +export default MoveSeriesModal; diff --git a/frontend/src/Movie/MoviePoster.js b/frontend/src/Movie/MoviePoster.js new file mode 100644 index 000000000..17c86264c --- /dev/null +++ b/frontend/src/Movie/MoviePoster.js @@ -0,0 +1,195 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LazyLoad from 'react-lazyload'; + +const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAKHklEQVR42u2c25bbKhJATTmUPAZKPerBjTMo0fn/n5wHSYBkXUDCnXPWwEPaneVIO0XdKAouzT9kXApoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gP4DQLXW+vF4GGMeD6211n87UK2NsW33OlprTB7eSw5I220PmwH2JKh+7EGOoj3Lejkly0hKx/pHQLVpu9RhzbeDHsEc1PU7QbXpDo/WfB/oQWmeUoADoMZ2Z4fV7wfV5zG7ruvMu0FPzvpxoV7+hDiPCDUJVLddzmHfBfqZlzONNAG0VrXNy/lB7wCtifKSth+KKD8oEREpshk5JRFRnRm0VkREJLKR2kYQERF9ZAUdHkokM5EO8iQiQRlBiSG552Yhdf91wfDf2UBrkj+Q6nyk9mPklAzj9PQSqZ/qR0aZtrWXZ0UUZfuXKL9ERBKzkdray/Nf/YcsoIrmpOcsynMKqMZHngfVn4MHJeVJz/jTYN7RORN1GlTb7tM5Eqw86fMg55Pc47jjpGY3698DtV3Xfgo1kjqZEulD4tTKafrVO+cP23WPU6Bm6vSC2SfVJK/w2p8fntPPu6ht13WtPgE6SK0dSeuQlMSn/ZWW1EvHWYGYOxF7AtTOAzMpHpKKRwqm8jpZMfHq7MxhUD+3bXMb9QmwdwIDqrYx6bS1WnhMuoWcrX/JQdBw5RHMPgQyJRKiee6w/rLmM8RclueOSC9RAp1YlPyBKnirEoK0sXZVlk9NQrh/URMhm9mRG/oQ6Mz/tKGehqRESgjVaGPsRLSttU+jGxJCBt+Vap1zy542QJ9/zYTjPL/iWAmasd4EUdNoYx7m68sYrT8/ahJTSlIVIrq/kc18HvQB0AWH3jhBIuN3ehlSSiGEFEoKIYWQcv4FVQGwSjlP3MavS9dBl2Lk5xiiGICPp/EDOQBzetMs6LVOBl2MkL/G5BkAYEmmm0NVAAAuIi1xrov0EmfyLqVQnhThni5Pz7mSgOlE0JXqTaulI0WAW4o8kfGAUz7SKlJroGuxsXUxiXO8Tn3/jjwZIvfypLUXJIKuppvGp+eAHKp4TkDwGaj4ufYCnQS6kWz6xBcQgVWRdsT4FcKMfjXqPpNAN1JN4xRT8CtCnEXdGCD6zI6E3citU0A3lkStEymJKwPGZQSoRBbIk+THRg6TArq5zDA+wxDAcMZZKymlVK82D2Ga9zO5En0A1AYUwYKiF5XAYQgxllbGZCD4FrXJ5d1Lqop2XauDd05EJypkDBgHYIxxrNaU4ra9ZaHjQTdX7a0Vaun1Aq8AAAA4/MGwWvzilimtzv0leea7rq0XRKVuwELQ4aNY4my+CbTTC69HAHgFDVx8sBIxB/YgLinx0/lkscgJiAgAHJEDICICcFyQqdirB0WD7lUWLKlXTgQERARE4IjAAThH5K+zv1+40rGguz0izUxJb6E4e9l6HeBzge7uVz1ygc6VVKBjG37wAHSeuIjdUpCJBd2tJ3yJeWY06OQg10GwAzuIN4Hu1+nMZOrltRclH7S0l2ivrr2Upzq6W7G02UCn1lQxBOQcOCBw4JwDAFwHSg4I04LF/vZfTlA5WWP04R0QARAAOSBcERGG31k493LfBNp8oB9yakq97cxCqDMohpO4tF9VywfaBDISzr4XItNAG/6/IkrV2UDb/wSgdzayIf+7gXYBaH1ng29yUdP/gtjHU+lz05jibz6J6kBEzoHy8AcfP3PEScD/VtBJaKogiJpjZOKBDuDE5X8r6K9dUJyA/j0kegevk5MQ6gIT+3NWryfuiY/JKALiFQA4R47IB2qc+tFvBW3UJDL1wkNAuCLnCPw6ps8c+VRFSex3T70pMlEfQgHh2ufPCFfoQ+iop6JOikzvSkrEIFG4YuDjPSibJCUyX1Kyn48+J6AKt0Mou6WtRBbrZMdAzbRmI9jo7H0kxd5FcYRplkdK7YKabEsRI2aFJeS9jY/pXv+p/3Cdre7Ef78NtJ0v7CUHQOQ4WHmf3l9HhzUv6Ox6fJ1tudzMl8CCuwwKAQBYYFWUvArVuQoQr+t6EnwlhOJrBXLPmtpsJR0jlkpki6CvnKT2KiXxJZ0dl/x7qfZECoE5VzrqwWLdfC8tiS+S7VjTZGk3FSrvSRGBM0Bc/p78sMkqeqSQ+9uKtVK9QAQGDBgDfNmAjq6SJYBul8b1pMo9V8D7XVTVXcwoJ1u82wlUSml8M8EJbV4s7TPVS9u17B5bw0/ZbNice7/RRAoZrJS/Z3bGryHp7Zlp+2Zr7n/7wrhEhvwSsXMrGOdhbrLVhWjTthjX5+Z584L6wafZ+wYpcM6idu5M2qat2d8LVQjIGaoYUKoY8nA7ct1Vp23ars+9EQEnxnIS3QEhIJUm8bTDZa/b7WUn1PW9AiCP5uzzlnD11MaXxQ+0anSurfKlSrdPOqk+r3RApPeULJ8Isr6PGID3IbJe959T5yqmK1Kb0qmx0U60KNJxmdwvN+W+q59F2LBg1sRv1m93ki11JXlDWszg9i0qUBelEwS6BfoqUqP8ImmZUykphRJCSKnUwhfuWAX9Gia+kWyz29Gu7IXUhFxUYjrPSgpxE5Lq/pDKR01S3MR8H1pJuju/r+SjjRXoJuhjbXMJ5+0ZStwENfpp+9H2P/pex9scVnjS2ZaTPdqRa5c7NJBNXy0ENcYud5Dap/mUNznbPxtnQ00TPn0UNHzKw8uTyWnvaGPtViZs22czTU/HjlxFMlyW2OPN2G5mfn+5PlAEFfaQyK+IJufWPijUAAxmX0e1OO/14VsnTznae6ifkqIPtLaGwjYd13AgHak5AzqkewEnHsLsSfzCpb77bkL5tdVBFnsEw/T27uwojEbJ526tDvR0fFKtpN6d+IjTN6brHtJHeOfyqTlyrCU4g+E9v1J62+LjzjNZV2NUXp5KHTrT0nWtVguzo/TuQeZ9UE2vJ1rUoFdHhlHSxVOvs1nO3PW5csgpjnN2nfGezulpplOMpKgO4qYSp07Zt0/n/hGpJlKZDgc2TdM/03m+R3dqtDOZRp0KjjxpK4GP+e5pzq7rjJfpj6wnbRvya50MnF3nZl8BNjlBGz/vpssx/Ow3eUHHc+syD+e4A6SiD9gn3FhARErl4uzXNapu3gDa1IrycXadIXrL1QpN09Q5ORPv/0i7pyQvqH4faM4bVRKvfkm+SyeTUJMvU0q/nSiLUNOvJzpy39Ppi3+OXPh06GIq/fzWWT8Oegb16F1vh295O3Z72uG7087cm6cT7/z66wTm2ZsIU8RqT93vd/puRx0n1/O3O+a4LVM/NmFtlvsyc90/qrUxz5fT4MZku4Q0/42uWue+I/VNoG8aBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpA/99B/wd7kHH8CSaCpAAAAABJRU5ErkJggg=='; + +function findPoster(images) { + return _.find(images, { coverType: 'poster' }); +} + +function getPosterUrl(poster, size) { + if (poster) { + // Remove protocol + let url = poster.url.replace(/^https?:/, ''); + url = url.replace('poster.jpg', `poster-${size}.jpg`); + + return url; + } +} + +class MoviePoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const pixelRatio = Math.ceil(window.devicePixelRatio); + + const { + images, + size + } = props; + + const poster = findPoster(images); + + this.state = { + pixelRatio, + poster, + posterUrl: getPosterUrl(poster, pixelRatio * size), + isLoaded: false, + hasError: false + }; + } + + componentDidMount() { + if (!this.state.posterUrl && this.props.onError) { + this.props.onError(); + } + } + + componentDidUpdate(prevProps, prevState) { + const { + images, + size, + onError + } = this.props; + + const { + poster, + pixelRatio + } = this.state; + + const nextPoster = findPoster(images); + + if (nextPoster && (!poster || nextPoster.url !== poster.url)) { + this.setState({ + poster: nextPoster, + posterUrl: getPosterUrl(nextPoster, pixelRatio * size), + hasError: false + // Don't reset isLoaded, as we want to immediately try to + // show the new image, whether an image was shown previously + // or the placeholder was shown. + }); + } else if (!nextPoster && poster) { + this.setState({ + poster: nextPoster, + posterUrl: posterPlaceholder, + hasError: false + }); + + if (onError) { + onError(); + } + } + } + + // + // Listeners + + onError = () => { + this.setState({ + hasError: true + }); + + if (this.props.onError) { + this.props.onError(); + } + } + + onLoad = () => { + this.setState({ + isLoaded: true, + hasError: false + }); + + if (this.props.onLoad) { + this.props.onLoad(); + } + } + + // + // Render + + render() { + const { + className, + style, + size, + lazy, + overflow + } = this.props; + + const { + posterUrl, + hasError, + isLoaded + } = this.state; + + if (hasError || !posterUrl) { + return ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +MoviePoster.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + size: PropTypes.number.isRequired, + lazy: PropTypes.bool.isRequired, + overflow: PropTypes.bool.isRequired, + onError: PropTypes.func, + onLoad: PropTypes.func +}; + +MoviePoster.defaultProps = { + size: 250, + lazy: true, + overflow: false +}; + +export default MoviePoster; diff --git a/frontend/src/Movie/MovieQuality.js b/frontend/src/Movie/MovieQuality.js new file mode 100644 index 000000000..105787c96 --- /dev/null +++ b/frontend/src/Movie/MovieQuality.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getTooltip(title, quality, size) { + const revision = quality.revision; + + if (revision.real && revision.real > 0) { + title += ' [REAL]'; + } + + if (revision.version && revision.version > 1) { + title += ' [PROPER]'; + } + + if (size) { + title += ` - ${formatBytes(size)}`; + } + + return title; +} + +function MovieQuality(props) { + const { + className, + title, + quality, + size, + isCutoffNotMet + } = props; + + return ( + + ); +} + +MovieQuality.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + quality: PropTypes.object.isRequired, + size: PropTypes.number, + isCutoffNotMet: PropTypes.bool +}; + +MovieQuality.defaultProps = { + title: '' +}; + +export default MovieQuality; diff --git a/frontend/src/Movie/MovieTitleLink.js b/frontend/src/Movie/MovieTitleLink.js new file mode 100644 index 000000000..5dccf64b5 --- /dev/null +++ b/frontend/src/Movie/MovieTitleLink.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import Link from 'Components/Link/Link'; + +class MovieTitleLink extends PureComponent { + + render() { + const { + titleSlug, + title + } = this.props; + + const link = `/movie/${titleSlug}`; + + return ( + + {title} + + ); + } +} + +MovieTitleLink.propTypes = { + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired +}; + +export default MovieTitleLink; diff --git a/frontend/src/Movie/NoMovie.css b/frontend/src/Movie/NoMovie.css new file mode 100644 index 000000000..38a01f391 --- /dev/null +++ b/frontend/src/Movie/NoMovie.css @@ -0,0 +1,11 @@ +.message { + margin-top: 10px; + margin-bottom: 30px; + text-align: center; + font-size: 20px; +} + +.buttonContainer { + margin-top: 20px; + text-align: center; +} diff --git a/frontend/src/Movie/NoMovie.js b/frontend/src/Movie/NoMovie.js new file mode 100644 index 000000000..0e768530f --- /dev/null +++ b/frontend/src/Movie/NoMovie.js @@ -0,0 +1,51 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import styles from './NoMovie.css'; + +function NoMovie(props) { + const { totalItems } = props; + + if (totalItems > 0) { + return ( +
+
+ All movies are hidden due to the applied filter. +
+
+ ); + } + + return ( +
+
+ No movies found, to get started you'll want to add a new movie or import some existing ones. +
+ +
+ +
+ +
+ +
+
+ ); +} + +NoMovie.propTypes = { + totalItems: PropTypes.number.isRequired +}; + +export default NoMovie; diff --git a/frontend/src/Movie/Search/SeasonInteractiveSearchModal.js b/frontend/src/Movie/Search/SeasonInteractiveSearchModal.js new file mode 100644 index 000000000..7973affba --- /dev/null +++ b/frontend/src/Movie/Search/SeasonInteractiveSearchModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeasonInteractiveSearchModalContent from './SeasonInteractiveSearchModalContent'; + +function SeasonInteractiveSearchModal(props) { + const { + isOpen, + seriesId, + seasonNumber, + onModalClose + } = props; + + return ( + + + + ); +} + +SeasonInteractiveSearchModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeasonInteractiveSearchModal; diff --git a/frontend/src/Movie/Search/SeasonInteractiveSearchModalConnector.js b/frontend/src/Movie/Search/SeasonInteractiveSearchModalConnector.js new file mode 100644 index 000000000..e270ebdec --- /dev/null +++ b/frontend/src/Movie/Search/SeasonInteractiveSearchModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import SeasonInteractiveSearchModal from './SeasonInteractiveSearchModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(cancelFetchReleases()); + dispatch(clearReleases()); + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(SeasonInteractiveSearchModal); diff --git a/frontend/src/Movie/Search/SeasonInteractiveSearchModalContent.js b/frontend/src/Movie/Search/SeasonInteractiveSearchModalContent.js new file mode 100644 index 000000000..51ca9702c --- /dev/null +++ b/frontend/src/Movie/Search/SeasonInteractiveSearchModalContent.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; + +function SeasonInteractiveSearchModalContent(props) { + const { + seriesId, + seasonNumber, + onModalClose + } = props; + + return ( + + + Interactive Search + + + + + + + + + + + ); +} + +SeasonInteractiveSearchModalContent.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeasonInteractiveSearchModalContent; diff --git a/frontend/src/Movie/movieEntities.js b/frontend/src/Movie/movieEntities.js new file mode 100644 index 000000000..32b276a4b --- /dev/null +++ b/frontend/src/Movie/movieEntities.js @@ -0,0 +1,9 @@ +export const CALENDAR = 'calendar'; +export const MOVIES = 'movies'; +export const INTERACTIVE_IMPORT = 'interactiveImport.movies'; + +export default { + CALENDAR, + MOVIES, + INTERACTIVE_IMPORT +}; diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModal.js b/frontend/src/MovieFile/Editor/MovieFileEditorModal.js new file mode 100644 index 000000000..eae5a7b6b --- /dev/null +++ b/frontend/src/MovieFile/Editor/MovieFileEditorModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import MovieFileEditorModalContentConnector from './MovieFileEditorModalContentConnector'; + +function MovieFileEditorModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +MovieFileEditorModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieFileEditorModal; diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.css b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.css new file mode 100644 index 000000000..49e946826 --- /dev/null +++ b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.css @@ -0,0 +1,8 @@ +.actions { + display: flex; + margin-right: auto; +} + +.selectInput { + margin-left: 10px; +} diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.js b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.js new file mode 100644 index 000000000..084d85663 --- /dev/null +++ b/frontend/src/MovieFile/Editor/MovieFileEditorModalContent.js @@ -0,0 +1,280 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import SelectInput from 'Components/Form/SelectInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import MovieFileEditorRow from './MovieFileEditorRow'; +import styles from './MovieFileEditorModalContent.css'; + +const columns = [ + { + name: 'episodeNumber', + label: 'Episode', + isVisible: true + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: true + }, + { + name: 'airDateUtc', + label: 'Air Date', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + } +]; + +class MovieFileEditorModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmDeleteModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.items !== this.props.items) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + const selectedIds = getSelectedIds(this.state.selectedState); + + return selectedIds.reduce((acc, id) => { + const matchingItem = this.props.items.find((item) => item.id === id); + + if (matchingItem && !acc.includes(matchingItem.episodeFileId)) { + acc.push(matchingItem.episodeFileId); + } + + return acc; + }, []); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.onDeletePress(this.getSelectedIds()); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onLanguageChange = ({ value }) => { + const selectedIds = this.getSelectedIds(); + + if (!selectedIds.length) { + return; + } + + this.props.onLanguageChange(selectedIds, parseInt(value)); + } + + onQualityChange = ({ value }) => { + const selectedIds = this.getSelectedIds(); + + if (!selectedIds.length) { + return; + } + + this.props.onQualityChange(selectedIds, parseInt(value)); + } + + // + // Render + + render() { + const { + isDeleting, + items, + languages, + qualities, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmDeleteModalOpen + } = this.state; + + const languageOptions = _.reduceRight(languages, (acc, language) => { + acc.push({ + key: language.id, + value: language.name + }); + + return acc; + }, [{ key: 'selectLanguage', value: 'Select Language', disabled: true }]); + + const qualityOptions = _.reduceRight(qualities, (acc, quality) => { + acc.push({ + key: quality.id, + value: quality.name + }); + + return acc; + }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]); + + const hasSelectedFiles = this.getSelectedIds().length > 0; + + return ( + + + Manage Episodes + + + + { + !items.length && +
+ No episode files to manage. +
+ } + + { + !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ + +
+ + Delete + + +
+ +
+ +
+ +
+
+ + +
+ + +
+ ); + } +} + +MovieFileEditorModalContent.propTypes = { + seasonNumber: PropTypes.number, + isDeleting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + onDeletePress: PropTypes.func.isRequired, + onLanguageChange: PropTypes.func.isRequired, + onQualityChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default MovieFileEditorModalContent; diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorModalContentConnector.js b/frontend/src/MovieFile/Editor/MovieFileEditorModalContentConnector.js new file mode 100644 index 000000000..f1ef55c7c --- /dev/null +++ b/frontend/src/MovieFile/Editor/MovieFileEditorModalContentConnector.js @@ -0,0 +1,102 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import { deleteMovieFiles, updateMovieFiles } from 'Store/Actions/movieFileActions'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import MovieFileEditorModalContent from './MovieFileEditorModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieFiles, + (state) => state.settings.qualityProfiles.schema, + createMovieSelector(), + ( + movieFiles, + qualityProfileSchema, + movie + ) => { + const qualities = getQualities(qualityProfileSchema.items); + + return { + items: movieFiles.items, + isDeleting: movieFiles.isDeleting, + isSaving: movieFiles.isSaving, + qualities + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchQualityProfileSchema(name, path) { + dispatch(fetchQualityProfileSchema()); + }, + + dispatchUpdateMovieFiles(updateProps) { + dispatch(updateMovieFiles(updateProps)); + }, + + onDeletePress(episodeFileIds) { + dispatch(deleteMovieFiles({ episodeFileIds })); + } + }; +} + +class MovieFileEditorModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityProfileSchema(); + } + + // + // Render + + // + // Listeners + + onQualityChange = (episodeFileIds, qualityId) => { + const quality = { + quality: _.find(this.props.qualities, { id: qualityId }), + revision: { + version: 1, + real: 0 + } + }; + + this.props.dispatchUpdateMovieFiles({ episodeFileIds, quality }); + } + + render() { + const { + dispatchFetchQualityProfileSchema, + dispatchUpdateMovieFiles, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +MovieFileEditorModalContentConnector.propTypes = { + movieId: PropTypes.number.isRequired, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, + dispatchUpdateMovieFiles: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(MovieFileEditorModalContentConnector); diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorRow.js b/frontend/src/MovieFile/Editor/MovieFileEditorRow.js new file mode 100644 index 000000000..bab071699 --- /dev/null +++ b/frontend/src/MovieFile/Editor/MovieFileEditorRow.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import MovieQuality from 'Movie/MovieQuality'; + +function MovieFileEditorRow(props) { + const { + id, + relativePath, + airDateUtc, + language, + quality, + isSelected, + onSelectedChange + } = props; + + return ( + + + + + {relativePath} + + + + + + + + + + + + + ); +} + +MovieFileEditorRow.propTypes = { + id: PropTypes.number.isRequired, + relativePath: PropTypes.string.isRequired, + airDateUtc: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + quality: PropTypes.object.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default MovieFileEditorRow; diff --git a/frontend/src/MovieFile/MediaInfo.js b/frontend/src/MovieFile/MediaInfo.js new file mode 100644 index 000000000..75b264d58 --- /dev/null +++ b/frontend/src/MovieFile/MediaInfo.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import * as mediaInfoTypes from './mediaInfoTypes'; + +function MediaInfo(props) { + const { + type, + audioChannels, + audioCodec, + videoCodec + } = props; + + if (type === mediaInfoTypes.AUDIO) { + return ( + + { + !!audioCodec && + audioCodec + } + + { + !!audioCodec && !!audioChannels && + ' - ' + } + + { + !!audioChannels && + audioChannels.toFixed(1) + } + + ); + } + + if (type === mediaInfoTypes.VIDEO) { + return ( + + {videoCodec} + + ); + } + + return null; +} + +MediaInfo.propTypes = { + type: PropTypes.string.isRequired, + audioChannels: PropTypes.number, + audioCodec: PropTypes.string, + videoCodec: PropTypes.string +}; + +export default MediaInfo; diff --git a/frontend/src/MovieFile/MediaInfoConnector.js b/frontend/src/MovieFile/MediaInfoConnector.js new file mode 100644 index 000000000..55dec12f7 --- /dev/null +++ b/frontend/src/MovieFile/MediaInfoConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector'; +import MediaInfo from './MediaInfo'; + +function createMapStateToProps() { + return createSelector( + createMovieFileSelector(), + (episodeFile) => { + if (episodeFile) { + return { + ...episodeFile.mediaInfo + }; + } + + return {}; + } + ); +} + +export default connect(createMapStateToProps)(MediaInfo); diff --git a/frontend/src/MovieFile/MovieFileLanguageConnector.js b/frontend/src/MovieFile/MovieFileLanguageConnector.js new file mode 100644 index 000000000..466b458da --- /dev/null +++ b/frontend/src/MovieFile/MovieFileLanguageConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; + +function createMapStateToProps() { + return createSelector( + createMovieFileSelector(), + (episodeFile) => { + return { + language: episodeFile ? episodeFile.language : undefined + }; + } + ); +} + +export default connect(createMapStateToProps)(EpisodeLanguage); diff --git a/frontend/src/MovieFile/mediaInfoTypes.js b/frontend/src/MovieFile/mediaInfoTypes.js new file mode 100644 index 000000000..5e5a78e64 --- /dev/null +++ b/frontend/src/MovieFile/mediaInfoTypes.js @@ -0,0 +1,2 @@ +export const AUDIO = 'audio'; +export const VIDEO = 'video'; diff --git a/frontend/src/Organize/OrganizePreviewModal.js b/frontend/src/Organize/OrganizePreviewModal.js new file mode 100644 index 000000000..647f4ddf8 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizePreviewModalContentConnector from './OrganizePreviewModalContentConnector'; + +function OrganizePreviewModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +OrganizePreviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModal; diff --git a/frontend/src/Organize/OrganizePreviewModalConnector.js b/frontend/src/Organize/OrganizePreviewModalConnector.js new file mode 100644 index 000000000..ace733c86 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import OrganizePreviewModal from './OrganizePreviewModal'; + +const mapDispatchToProps = { + clearOrganizePreview +}; + +class OrganizePreviewModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearOrganizePreview(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalConnector.propTypes = { + clearOrganizePreview: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(OrganizePreviewModalConnector); diff --git a/frontend/src/Organize/OrganizePreviewModalContent.css b/frontend/src/Organize/OrganizePreviewModalContent.css new file mode 100644 index 000000000..7de056fcc --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.css @@ -0,0 +1,24 @@ +.path { + margin-left: 5px; + font-weight: bold; +} + +.episodeFormat { + margin-left: 5px; + font-family: $monoSpaceFontFamily; +} + +.previews { + margin-top: 10px; +} + +.selectAllInputContainer { + margin-right: auto; + line-height: 30px; +} + +.selectAllInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js new file mode 100644 index 000000000..319d8e72a --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.js @@ -0,0 +1,201 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CheckInput from 'Components/Form/CheckInput'; +import OrganizePreviewRow from './OrganizePreviewRow'; +import styles from './OrganizePreviewModalContent.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +class OrganizePreviewModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onOrganizePress = () => { + this.props.onOrganizePress(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + renameEpisodes, + episodeFormat, + path, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const selectAllValue = getValue(allSelected, allUnselected); + + return ( + + + Organize & Rename + + + + { + isFetching && + + } + + { + !isFetching && error && +
Error loading previews
+ } + + { + !isFetching && isPopulated && !items.length && +
+ { + renameEpisodes ? +
Success! My work is done, no files to rename.
: +
Renaming is disabled, nothing to rename
+ } +
+ } + + { + !isFetching && isPopulated && !!items.length && +
+ +
+ All paths are relative to: + + {path} + +
+ +
+ Naming pattern: + + {episodeFormat} + +
+
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+ } +
+ + + { + isPopulated && !!items.length && + + } + + + + + +
+ ); + } +} + +OrganizePreviewModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + seasonNumber: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + renameEpisodes: PropTypes.bool, + episodeFormat: PropTypes.string, + onOrganizePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModalContent; diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js new file mode 100644 index 000000000..a46c62ab9 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.js @@ -0,0 +1,91 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import { fetchNamingSettings } from 'Store/Actions/settingsActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizePreviewModalContent from './OrganizePreviewModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.organizePreview, + (state) => state.settings.naming, + createMovieSelector(), + (organizePreview, naming, series) => { + const props = { ...organizePreview }; + props.isFetching = organizePreview.isFetching || naming.isFetching; + props.isPopulated = organizePreview.isPopulated && naming.isPopulated; + props.error = organizePreview.error || naming.error; + props.renameEpisodes = naming.item.renameEpisodes; + props.episodeFormat = naming.item.episodeFormat; + props.path = series.path; + + return props; + } + ); +} + +const mapDispatchToProps = { + fetchOrganizePreview, + fetchNamingSettings, + executeCommand +}; + +class OrganizePreviewModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchOrganizePreview({ + seriesId, + seasonNumber + }); + + this.props.fetchNamingSettings(); + } + + // + // Listeners + + onOrganizePress = (files) => { + this.props.executeCommand({ + name: commandNames.RENAME_FILES, + seriesId: this.props.seriesId, + files + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + fetchOrganizePreview: PropTypes.func.isRequired, + fetchNamingSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizePreviewModalContentConnector); diff --git a/frontend/src/Organize/OrganizePreviewRow.css b/frontend/src/Organize/OrganizePreviewRow.css new file mode 100644 index 000000000..1b3c8ca47 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.css @@ -0,0 +1,20 @@ +.row { + display: flex; + margin-bottom: 5px; + padding: 5px 0; + border-bottom: 1px solid $borderColor; + + &:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } +} + +.selectedContainer { + margin-right: 30px; +} + +.path { + margin-left: 10px; +} diff --git a/frontend/src/Organize/OrganizePreviewRow.js b/frontend/src/Organize/OrganizePreviewRow.js new file mode 100644 index 000000000..340232a98 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './OrganizePreviewRow.css'; + +class OrganizePreviewRow extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: true }); + } + + // + // Listeners + + onSelectedChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + id, + existingPath, + newPath, + isSelected + } = this.props; + + return ( +
+ + +
+
+ + + + {existingPath} + +
+ +
+ + + + {newPath} + +
+
+
+ ); + } +} + +OrganizePreviewRow.propTypes = { + id: PropTypes.number.isRequired, + existingPath: PropTypes.string.isRequired, + newPath: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default OrganizePreviewRow; diff --git a/frontend/src/RootFolder/RootFolderRow.css b/frontend/src/RootFolder/RootFolderRow.css new file mode 100644 index 000000000..d9c5ccb01 --- /dev/null +++ b/frontend/src/RootFolder/RootFolderRow.css @@ -0,0 +1,18 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + display: block; +} + +.freeSpace, +.unmappedFolders { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/RootFolder/RootFolderRow.js b/frontend/src/RootFolder/RootFolderRow.js new file mode 100644 index 000000000..2a4038a54 --- /dev/null +++ b/frontend/src/RootFolder/RootFolderRow.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './RootFolderRow.css'; + +function RootFolderRow(props) { + const { + id, + path, + freeSpace, + unmappedFolders, + onDeletePress + } = props; + + const unmappedFoldersCount = unmappedFolders.length || '-'; + + return ( + + + + {path} + + + + + {formatBytes(freeSpace) || '-'} + + + + {unmappedFoldersCount} + + + + + + + ); +} + +RootFolderRow.propTypes = { + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + freeSpace: PropTypes.number.isRequired, + unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onDeletePress: PropTypes.func.isRequired +}; + +RootFolderRow.defaultProps = { + freeSpace: 0, + unmappedFolders: [] +}; + +export default RootFolderRow; diff --git a/frontend/src/RootFolder/RootFolderRowConnector.js b/frontend/src/RootFolder/RootFolderRowConnector.js new file mode 100644 index 000000000..ab0848e87 --- /dev/null +++ b/frontend/src/RootFolder/RootFolderRowConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import RootFolderRow from './RootFolderRow'; + +function createMapDispatchToProps(dispatch, props) { + return { + onDeletePress() { + dispatch(deleteRootFolder({ id: props.id })); + } + }; +} + +export default connect(null, createMapDispatchToProps)(RootFolderRow); diff --git a/frontend/src/RootFolder/RootFolders.js b/frontend/src/RootFolder/RootFolders.js new file mode 100644 index 000000000..57598dbb9 --- /dev/null +++ b/frontend/src/RootFolder/RootFolders.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import RootFolderRowConnector from './RootFolderRowConnector'; + +const rootFolderColumns = [ + { + name: 'path', + label: 'Path', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'unmappedFolders', + label: 'Unmapped Folders', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function RootFolders(props) { + const { + isFetching, + isPopulated, + error, + items + } = props; + + if (isFetching && !isPopulated) { + return ( + + ); + } + + if (!isFetching && !!error) { + return ( +
Unable to load root folders
+ ); + } + + return ( + + + { + items.map((rootFolder) => { + return ( + + ); + }) + } + +
+ ); +} + +RootFolders.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default RootFolders; diff --git a/frontend/src/RootFolder/RootFoldersConnector.js b/frontend/src/RootFolder/RootFoldersConnector.js new file mode 100644 index 000000000..39f140bcc --- /dev/null +++ b/frontend/src/RootFolder/RootFoldersConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import RootFolders from './RootFolders'; + +function createMapStateToProps() { + return createSelector( + (state) => state.rootFolders, + (rootFolders) => { + return rootFolders; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchRootFolders: fetchRootFolders +}; + +class RootFoldersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RootFoldersConnector.propTypes = { + dispatchFetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector); diff --git a/frontend/src/Settings/AdvancedSettingsButton.css b/frontend/src/Settings/AdvancedSettingsButton.css new file mode 100644 index 000000000..4b7460ea2 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.css @@ -0,0 +1,31 @@ +.button { + composes: toolbarButton from 'Components/Page/Toolbar/PageToolbarButton.css'; + + position: relative; +} + +.labelContainer { + composes: labelContainer from 'Components/Page/Toolbar/PageToolbarButton.css'; +} + +.label { + composes: label from 'Components/Page/Toolbar/PageToolbarButton.css'; +} + +.indicatorContainer { + position: absolute; + top: 10px; + right: 12px; +} + +.indicatorBackground { + color: $themeDarkColor; +} + +.enabled { + color: $successColor; +} + +.disabled { + color: $dangerColor; +} diff --git a/frontend/src/Settings/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js new file mode 100644 index 000000000..12d9902d5 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './AdvancedSettingsButton.css'; + +function AdvancedSettingsButton(props) { + const { + advancedSettings, + onAdvancedSettingsPress + } = props; + + return ( + + + + + + + + + +
+
+ {advancedSettings ? 'Hide Advanced' : 'Show Advanced'} +
+
+ + ); +} + +AdvancedSettingsButton.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + onAdvancedSettingsPress: PropTypes.func.isRequired +}; + +export default AdvancedSettingsButton; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js new file mode 100644 index 000000000..c82604a88 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; +import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; + +class DownloadClientSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isTestingAll, + dispatchTestAllDownloadClients + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + + + ); + } +} + +DownloadClientSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired +}; + +export default DownloadClientSettings; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js new file mode 100644 index 000000000..5e1a8a1ca --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllDownloadClients } from 'Store/Actions/settingsActions'; +import DownloadClientSettings from './DownloadClientSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllDownloadClients: testAllDownloadClients +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css new file mode 100644 index 000000000..e1032ddef --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css @@ -0,0 +1,44 @@ +.downloadClient { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js new file mode 100644 index 000000000..3a2265d28 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem'; +import styles from './AddDownloadClientItem.css'; + +class AddDownloadClientItem extends Component { + + // + // Listeners + + onDownloadClientSelect = () => { + const { + implementation + } = this.props; + + this.props.onDownloadClientSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onDownloadClientSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddDownloadClientItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onDownloadClientSelect: PropTypes.func.isRequired +}; + +export default AddDownloadClientItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js new file mode 100644 index 000000000..0c21e7dbd --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector'; + +function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css new file mode 100644 index 000000000..b4d5c6787 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css @@ -0,0 +1,5 @@ +.downloadClients { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js new file mode 100644 index 000000000..a441f4327 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddDownloadClientItem from './AddDownloadClientItem'; +import styles from './AddDownloadClientModalContent.css'; + +class AddDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients, + onDownloadClientSelect, + onModalClose + } = this.props; + + return ( + + + Add DownloadClient + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new downloadClient, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Radarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
+
For more information on the individual downloadClients, clink on the info buttons.
+
+ +
+
+ { + usenetDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+ +
+
+ { + torrentDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +AddDownloadClientModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + onDownloadClientSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js new file mode 100644 index 000000000..99d5c4f19 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions'; +import AddDownloadClientModalContent from './AddDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (downloadClients) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = downloadClients; + + const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' }); + const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients + }; + } + ); +} + +const mapDispatchToProps = { + fetchDownloadClientSchema, + selectDownloadClientSchema +}; + +class AddDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClientSchema(); + } + + // + // Listeners + + onDownloadClientSelect = ({ implementation }) => { + this.props.selectDownloadClientSchema({ implementation }); + this.props.onModalClose({ downloadClientSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddDownloadClientModalContentConnector.propTypes = { + fetchDownloadClientSchema: PropTypes.func.isRequired, + selectDownloadClientSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js new file mode 100644 index 000000000..f356f8140 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddDownloadClientPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddDownloadClientPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddDownloadClientPresetMenuItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css new file mode 100644 index 000000000..cfeacec77 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css @@ -0,0 +1,19 @@ +.downloadClient { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js new file mode 100644 index 000000000..6a86fef16 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -0,0 +1,113 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClient.css'; + +class DownloadClient extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onEditDownloadClientPress = () => { + this.setState({ isEditDownloadClientModalOpen: true }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + onDeleteDownloadClientPress = () => { + this.setState({ + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: true + }); + } + + onDeleteDownloadClientModalClose= () => { + this.setState({ isDeleteDownloadClientModalOpen: false }); + } + + onConfirmDeleteDownloadClient = () => { + this.props.onConfirmDeleteDownloadClient(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enable + } = this.props; + + return ( + +
+ {name} +
+ +
+ { + enable ? + : + + } +
+ + + + +
+ ); + } +} + +DownloadClient.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClient; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css new file mode 100644 index 000000000..ad53e6311 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css @@ -0,0 +1,20 @@ +.downloadClients { + display: flex; + flex-wrap: wrap; +} + +.addDownloadClient { + composes: downloadClient from './DownloadClient.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js new file mode 100644 index 000000000..029845025 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import DownloadClient from './DownloadClient'; +import AddDownloadClientModal from './AddDownloadClientModal'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClients.css'; + +class DownloadClients extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onAddDownloadClientPress = () => { + this.setState({ isAddDownloadClientModalOpen: true }); + } + + onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => { + this.setState({ + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: downloadClientSelected + }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteDownloadClient, + ...otherProps + } = this.props; + + const { + isAddDownloadClientModalOpen, + isEditDownloadClientModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +DownloadClients.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClients; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js new file mode 100644 index 000000000..d318bc163 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClients, deleteDownloadClient } from 'Store/Actions/settingsActions'; +import DownloadClients from './DownloadClients'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (downloadClients) => { + return { + ...downloadClients + }; + } + ); +} + +const mapDispatchToProps = { + fetchDownloadClients, + deleteDownloadClient +}; + +class DownloadClientsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClients(); + } + + // + // Listeners + + onConfirmDeleteDownloadClient = (id) => { + this.props.deleteDownloadClient({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientsConnector.propTypes = { + fetchDownloadClients: PropTypes.func.isRequired, + deleteDownloadClient: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js new file mode 100644 index 000000000..f6b07599c --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector'; + +function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js new file mode 100644 index 000000000..b5e5520fb --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestDownloadClient, cancelSaveDownloadClient } from 'Store/Actions/settingsActions'; +import EditDownloadClientModal from './EditDownloadClientModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.downloadClients'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestDownloadClient() { + dispatch(cancelTestDownloadClient({ section })); + }, + + dispatchCancelSaveDownloadClient() { + dispatch(cancelSaveDownloadClient({ section })); + } + }; +} + +class EditDownloadClientModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestDownloadClient(); + this.props.dispatchCancelSaveDownloadClient(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestDownloadClient, + dispatchCancelSaveDownloadClient, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditDownloadClientModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestDownloadClient: PropTypes.func.isRequired, + dispatchCancelSaveDownloadClient: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css new file mode 100644 index 000000000..c73406b57 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from 'Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js new file mode 100644 index 000000000..60eff3eb8 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditDownloadClientModalContent.css'; + +class EditDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteDownloadClientPress, + ...otherProps + } = this.props; + + const { + id, + implementationName, + name, + enable, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Download Client - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new download client, please try again.
+ } + + { + !isFetching && !error && +
+ { + !!message && + + {message.value.message} + + } + + + Name + + + + + + Enable + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); + } +} + +EditDownloadClientModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isTesting: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteDownloadClientPress: PropTypes.func +}; + +export default EditDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js new file mode 100644 index 000000000..75f6f0bc3 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions'; +import EditDownloadClientModalContent from './EditDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('downloadClients'), + (advancedSettings, downloadClient) => { + return { + advancedSettings, + ...downloadClient + }; + } + ); +} + +const mapDispatchToProps = { + setDownloadClientValue, + setDownloadClientFieldValue, + saveDownloadClient, + testDownloadClient +}; + +class EditDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setDownloadClientValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setDownloadClientFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveDownloadClient({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testDownloadClient({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDownloadClientModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setDownloadClientValue: PropTypes.func.isRequired, + setDownloadClientFieldValue: PropTypes.func.isRequired, + saveDownloadClient: PropTypes.func.isRequired, + testDownloadClient: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js new file mode 100644 index 000000000..38a391891 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js @@ -0,0 +1,135 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function DownloadClientOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load download client options
+ } + + { + hasSettings && !isFetching && !error && +
+
+
+ + Enable + + + + + + Remove + + + + + + Check For Finished Downloads Interval + + + +
+
+ +
+
+ + Redownload + + + + + + Remove + + + +
+
+
+ } +
+ ); +} + +DownloadClientOptions.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default DownloadClientOptions; diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js new file mode 100644 index 000000000..ef97de47f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchDownloadClientOptions, setDownloadClientOptionsValue, saveDownloadClientOptions } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import DownloadClientOptions from './DownloadClientOptions'; + +const SECTION = 'downloadClientOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDownloadClientOptions: fetchDownloadClientOptions, + dispatchSetDownloadClientOptionsValue: setDownloadClientOptionsValue, + dispatchSaveDownloadClientOptions: saveDownloadClientOptions, + dispatchClearPendingChanges: clearPendingChanges +}; + +class DownloadClientOptionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchDownloadClientOptions, + dispatchSaveDownloadClientOptions, + onChildMounted + } = this.props; + + dispatchFetchDownloadClientOptions(); + onChildMounted(dispatchSaveDownloadClientOptions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetDownloadClientOptionsValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientOptionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchDownloadClientOptions: PropTypes.func.isRequired, + dispatchSetDownloadClientOptionsValue: PropTypes.func.isRequired, + dispatchSaveDownloadClientOptions: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientOptionsConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js new file mode 100644 index 000000000..f66113619 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditRemotePathMappingModalContentConnector from './EditRemotePathMappingModalContentConnector'; + +function EditRemotePathMappingModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditRemotePathMappingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditRemotePathMappingModal; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js new file mode 100644 index 000000000..94172429d --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditRemotePathMappingModal from './EditRemotePathMappingModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditRemotePathMappingModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.remotePathMappings' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRemotePathMappingModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css new file mode 100644 index 000000000..0071acc4e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css @@ -0,0 +1,12 @@ +.body { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + flex: 1 1 430px; +} + +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} + diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js new file mode 100644 index 000000000..59c1cb498 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditRemotePathMappingModalContent.css'; + +function EditRemotePathMappingModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + onInputChange, + onSavePress, + onModalClose, + onDeleteRemotePathMappingPress, + ...otherProps + } = props; + + const { + host, + remotePath, + localPath + } = item; + + return ( + + + {id ? 'Edit Remote Path Mapping' : 'Add Remote Path Mapping'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new remote path mapping, please try again.
+ } + + { + !isFetching && !error && +
+ + Host + + + + + + Remote Path + + + + + + Local Path + + + +
+ } +
+ + + { + id && + + } + + + + + Save + + +
+ ); +} + +const remotePathMappingShape = { + host: PropTypes.shape(stringSettingShape).isRequired, + remotePath: PropTypes.shape(stringSettingShape).isRequired, + localPath: PropTypes.shape(stringSettingShape).isRequired +}; + +EditRemotePathMappingModalContent.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.shape(remotePathMappingShape).isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteRemotePathMappingPress: PropTypes.func +}; + +export default EditRemotePathMappingModalContent; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js new file mode 100644 index 000000000..00aa7b8ac --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js @@ -0,0 +1,119 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setRemotePathMappingValue, saveRemotePathMapping } from 'Store/Actions/settingsActions'; +import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent'; + +const newRemotePathMapping = { + host: '', + remotePath: '', + localPath: '' +}; + +function createRemotePathMappingSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.remotePathMappings, + (id, remotePathMappings) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = remotePathMappings; + + const mapping = id ? _.find(items, { id }) : newRemotePathMapping; + const settings = selectSettings(mapping, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createRemotePathMappingSelector(), + (remotePathMapping) => { + return { + ...remotePathMapping + }; + } + ); +} + +const mapDispatchToProps = { + setRemotePathMappingValue, + saveRemotePathMapping +}; + +class EditRemotePathMappingModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newRemotePathMapping).forEach((name) => { + this.props.setRemotePathMappingValue({ + name, + value: newRemotePathMapping[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setRemotePathMappingValue({ name, value }); + } + + onSavePress = () => { + this.props.saveRemotePathMapping({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRemotePathMappingModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setRemotePathMappingValue: PropTypes.func.isRequired, + saveRemotePathMapping: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css new file mode 100644 index 000000000..a79efda26 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css @@ -0,0 +1,23 @@ +.remotePathMapping { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.host { + flex: 0 0 300px; +} + +.path { + flex: 0 0 400px; +} + +.actions { + display: flex; + justify-content: flex-end; + flex: 1 0 auto; + padding-right: 10px; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js new file mode 100644 index 000000000..3f25dbd0f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMapping.css'; + +class RemotePathMapping extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onEditRemotePathMappingPress = () => { + this.setState({ isEditRemotePathMappingModalOpen: true }); + } + + onEditRemotePathMappingModalClose = () => { + this.setState({ isEditRemotePathMappingModalOpen: false }); + } + + onDeleteRemotePathMappingPress = () => { + this.setState({ + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: true + }); + } + + onDeleteRemotePathMappingModalClose = () => { + this.setState({ isDeleteRemotePathMappingModalOpen: false }); + } + + onConfirmDeleteRemotePathMapping = () => { + this.props.onConfirmDeleteRemotePathMapping(this.props.id); + } + + // + // Render + + render() { + const { + id, + host, + remotePath, + localPath + } = this.props; + + return ( +
+
{host}
+
{remotePath}
+
{localPath}
+ +
+ + + +
+ + + + +
+ ); + } +} + +RemotePathMapping.propTypes = { + id: PropTypes.number.isRequired, + host: PropTypes.string.isRequired, + remotePath: PropTypes.string.isRequired, + localPath: PropTypes.string.isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +RemotePathMapping.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default RemotePathMapping; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css new file mode 100644 index 000000000..4ef9dcb0f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css @@ -0,0 +1,23 @@ +.remotePathMappingsHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.host { + flex: 0 0 300px; +} + +.path { + flex: 0 0 400px; +} + +.addRemotePathMapping { + display: flex; + justify-content: flex-end; + padding-right: 10px; +} + +.addButton { + text-align: center; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js new file mode 100644 index 000000000..f633a3279 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import RemotePathMapping from './RemotePathMapping'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMappings.css'; + +class RemotePathMappings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onAddRemotePathMappingPress = () => { + this.setState({ isAddRemotePathMappingModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddRemotePathMappingModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteRemotePathMapping, + ...otherProps + } = this.props; + + return ( +
+ +
+
Host
+
Remote Path
+
Local Path
+
+ +
+ { + items.map((item, index) => { + return ( + + ); + }) + } +
+ +
+ + + +
+ + +
+
+ ); + } +} + +RemotePathMappings.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default RemotePathMappings; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js new file mode 100644 index 000000000..4900119a3 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRemotePathMappings, deleteRemotePathMapping } from 'Store/Actions/settingsActions'; +import RemotePathMappings from './RemotePathMappings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.remotePathMappings, + (remotePathMappings) => { + return { + ...remotePathMappings + }; + } + ); +} + +const mapDispatchToProps = { + fetchRemotePathMappings, + deleteRemotePathMapping +}; + +class RemotePathMappingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchRemotePathMappings(); + } + + // + // Listeners + + onConfirmDeleteRemotePathMapping = (id) => { + this.props.deleteRemotePathMapping({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RemotePathMappingsConnector.propTypes = { + fetchRemotePathMappings: PropTypes.func.isRequired, + deleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector); diff --git a/frontend/src/Settings/General/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js new file mode 100644 index 000000000..10df6a9b2 --- /dev/null +++ b/frontend/src/Settings/General/AnalyticSettings.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function AnalyticSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + analyticsEnabled + } = settings; + + return ( +
+ + Send Anonymous Usage Data + + + +
+ ); +} + +AnalyticSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default AnalyticSettings; diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js new file mode 100644 index 000000000..8b416fbc1 --- /dev/null +++ b/frontend/src/Settings/General/BackupSettings.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function BackupSettings(props) { + const { + advancedSettings, + settings, + onInputChange + } = props; + + const { + backupFolder, + backupInterval, + backupRetention + } = settings; + + if (!advancedSettings) { + return null; + } + + return ( +
+ + Folder + + + + + + Interval + + + + + + Retention + + + +
+ ); +} + +BackupSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default BackupSettings; diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js new file mode 100644 index 000000000..772112582 --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -0,0 +1,210 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import AnalyticSettings from './AnalyticSettings'; +import BackupSettings from './BackupSettings'; +import HostSettings from './HostSettings'; +import LoggingSettings from './LoggingSettings'; +import ProxySettings from './ProxySettings'; +import SecuritySettings from './SecuritySettings'; +import UpdateSettings from './UpdateSettings'; + +class GeneralSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestartRequiredModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + const { + settings, + isSaving, + saveError + } = this.props; + + if (isSaving || saveError || !prevProps.isSaving) { + return; + } + + const prevSettings = prevProps.settings; + + const keys = [ + 'bindAddress', + 'port', + 'urlBase', + 'enableSsl', + 'sslPort', + 'sslCertHash', + 'authenticationMethod', + 'username', + 'password', + 'apiKey' + ]; + + const pendingRestart = _.some(keys, (key) => { + const setting = settings[key]; + const prevSetting = prevSettings[key]; + + if (!setting || !prevSetting) { + return false; + } + + const previousValue = prevSetting.previousValue; + const value = setting.value; + + return previousValue != null && previousValue !== value; + }); + + this.setState({ isRestartRequiredModalOpen: pendingRestart }); + } + + // + // Listeners + + onConfirmRestart = () => { + this.setState({ isRestartRequiredModalOpen: false }); + this.props.onConfirmRestart(); + } + + onCloseRestartRequiredModalOpen = () => { + this.setState({ isRestartRequiredModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + isPopulated, + error, + settings, + hasSettings, + isResettingApiKey, + isMono, + isWindows, + mode, + onInputChange, + onConfirmResetApiKey, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
Unable to load General settings
+ } + + { + hasSettings && isPopulated && !error && +
+ + + + + + + + + + + + + + + } +
+ + +
+ ); + } + +} + +GeneralSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + hasSettings: PropTypes.bool.isRequired, + isMono: PropTypes.bool.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired, + onConfirmRestart: PropTypes.func.isRequired +}; + +export default GeneralSettings; diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js new file mode 100644 index 000000000..804fdfde7 --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettingsConnector.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { setGeneralSettingsValue, saveGeneralSettings, fetchGeneralSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { restart } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import GeneralSettings from './GeneralSettings'; + +const SECTION = 'general'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + createCommandExecutingSelector(commandNames.RESET_API_KEY), + createSystemStatusSelector(), + (advancedSettings, sectionSettings, isResettingApiKey, systemStatus) => { + return { + advancedSettings, + isResettingApiKey, + isMono: systemStatus.isMono, + isWindows: systemStatus.isWindows, + mode: systemStatus.mode, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setGeneralSettingsValue, + saveGeneralSettings, + fetchGeneralSettings, + executeCommand, + restart, + clearPendingChanges +}; + +class GeneralSettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchGeneralSettings(); + } + + componentDidUpdate(prevProps) { + if (!this.props.isResettingApiKey && prevProps.isResettingApiKey) { + this.props.fetchGeneralSettings(); + } + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setGeneralSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveGeneralSettings(); + } + + onConfirmResetApiKey = () => { + this.props.executeCommand({ name: commandNames.RESET_API_KEY }); + } + + onConfirmRestart = () => { + this.props.restart(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +GeneralSettingsConnector.propTypes = { + isResettingApiKey: PropTypes.bool.isRequired, + setGeneralSettingsValue: PropTypes.func.isRequired, + saveGeneralSettings: PropTypes.func.isRequired, + fetchGeneralSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + restart: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(GeneralSettingsConnector); diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js new file mode 100644 index 000000000..42c97e0dd --- /dev/null +++ b/frontend/src/Settings/General/HostSettings.js @@ -0,0 +1,154 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function HostSettings(props) { + const { + advancedSettings, + settings, + isWindows, + mode, + onInputChange + } = props; + + const { + bindAddress, + port, + urlBase, + enableSsl, + sslPort, + sslCertHash, + launchBrowser + } = settings; + + return ( +
+ + Bind Address + + + + + + Port Number + + + + + + URL Base + + + + + + Enable SSL + + + + + { + enableSsl.value && + + SSL Port + + + + } + + { + isWindows && enableSsl.value && + + SSL Cert Hash + + + + } + + { + mode !== 'service' && + + Open browser on start + + + + } + +
+ ); +} + +HostSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default HostSettings; diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js new file mode 100644 index 000000000..e7853328e --- /dev/null +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function LoggingSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + logLevel + } = settings; + + const logLevelOptions = [ + { key: 'info', value: 'Info' }, + { key: 'debug', value: 'Debug' }, + { key: 'trace', value: 'Trace' } + ]; + + return ( +
+ + Log Level + + + +
+ ); +} + +LoggingSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default LoggingSettings; diff --git a/frontend/src/Settings/General/ProxySettings.js b/frontend/src/Settings/General/ProxySettings.js new file mode 100644 index 000000000..238cf3a30 --- /dev/null +++ b/frontend/src/Settings/General/ProxySettings.js @@ -0,0 +1,142 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function ProxySettings(props) { + const { + settings, + onInputChange + } = props; + + const { + proxyEnabled, + proxyType, + proxyHostname, + proxyPort, + proxyUsername, + proxyPassword, + proxyBypassFilter, + proxyBypassLocalAddresses + } = settings; + + const proxyTypeOptions = [ + { key: 'http', value: 'HTTP(S)' }, + { key: 'socks4', value: 'Socks4' }, + { key: 'socks5', value: 'Socks5 (Support TOR)' } + ]; + + return ( +
+ + Use Proxy + + + + + { + proxyEnabled.value && +
+ + Proxy Type + + + + + + Hostname + + + + + + Port + + + + + + Username + + + + + + Password + + + + + + Ignored Addresses + + + + + + Bypass Proxy for Local Addresses + + + +
+ } +
+ ); +} + +ProxySettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default ProxySettings; diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js new file mode 100644 index 000000000..0bef91050 --- /dev/null +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; + +class SecuritySettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmApiKeyResetModalOpen: false + }; + } + + // + // Listeners + + onApikeyFocus = (event) => { + event.target.select(); + } + + onResetApiKeyPress = () => { + this.setState({ isConfirmApiKeyResetModalOpen: true }); + } + + onConfirmResetApiKey = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + this.props.onConfirmResetApiKey(); + } + + onCloseResetApiKeyModal = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + } + + // + // Render + + render() { + const { + settings, + isResettingApiKey, + onInputChange + } = this.props; + + const { + authenticationMethod, + username, + password, + apiKey + } = settings; + + const authenticationMethodOptions = [ + { key: 'none', value: 'None' }, + { key: 'basic', value: 'Basic (Browser Popup)' }, + { key: 'forms', value: 'Forms (Login Page)' } + ]; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + return ( +
+ + Authentication + + + + + { + authenticationEnabled && + + Username + + + + } + + { + authenticationEnabled && + + Password + + + + } + + + API Key + + , + + + + + ]} + onChange={onInputChange} + onFocus={this.onApikeyFocus} + {...apiKey} + /> + + + +
+ ); + } +} + +SecuritySettings.propTypes = { + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired +}; + +export default SecuritySettings; diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js new file mode 100644 index 000000000..4a7b02d85 --- /dev/null +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function UpdateSettings(props) { + const { + advancedSettings, + settings, + isMono, + onInputChange + } = props; + + const { + branch, + updateAutomatically, + updateMechanism, + updateScriptPath + } = settings; + + if (!advancedSettings) { + return null; + } + + const updateOptions = [ + { key: 'builtIn', value: 'Built-In' }, + { key: 'script', value: 'Script' } + ]; + + return ( +
+ + Branch + + + + + { + isMono && +
+ + Automatic + + + + + + Mechanism + + + + + { + updateMechanism.value === 'script' && + + Script Path + + + + } +
+ } +
+ ); +} + +UpdateSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isMono: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UpdateSettings; diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js new file mode 100644 index 000000000..161224999 --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import IndexersConnector from './Indexers/IndexersConnector'; +import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; +import RestrictionsConnector from './Restrictions/RestrictionsConnector'; + +class IndexerSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // Render + // + + render() { + const { + isTestingAll, + dispatchTestAllIndexers + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + + + ); + } +} + +IndexerSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default IndexerSettings; diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js new file mode 100644 index 000000000..1eaf098d7 --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllIndexers } from 'Store/Actions/settingsActions'; +import IndexerSettings from './IndexerSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllIndexers: testAllIndexers +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSettings); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css new file mode 100644 index 000000000..d228b842b --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css @@ -0,0 +1,44 @@ +.indexer { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js new file mode 100644 index 000000000..21db4ecf1 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem'; +import styles from './AddIndexerItem.css'; + +class AddIndexerItem extends Component { + + // + // Listeners + + onIndexerSelect = () => { + const { + implementation + } = this.props; + + this.props.onIndexerSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onIndexerSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddIndexerItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onIndexerSelect: PropTypes.func.isRequired +}; + +export default AddIndexerItem; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js new file mode 100644 index 000000000..d05e8eb9a --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddIndexerModalContentConnector from './AddIndexerModalContentConnector'; + +function AddIndexerModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddIndexerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddIndexerModal; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css new file mode 100644 index 000000000..946305dff --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css @@ -0,0 +1,5 @@ +.indexers { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js new file mode 100644 index 000000000..59f74e4a2 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddIndexerItem from './AddIndexerItem'; +import styles from './AddIndexerModalContent.css'; + +class AddIndexerModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers, + onIndexerSelect, + onModalClose + } = this.props; + + return ( + + + Add Indexer + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new indexer, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Radarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
+
For more information on the individual indexers, clink on the info buttons.
+
+ +
+
+ { + usenetIndexers.map((indexer) => { + return ( + + ); + }) + } +
+
+ +
+
+ { + torrentIndexers.map((indexer) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +AddIndexerModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + onIndexerSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddIndexerModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js new file mode 100644 index 000000000..d79f028da --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions'; +import AddIndexerModalContent from './AddIndexerModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = indexers; + + const usenetIndexers = _.filter(schema, { protocol: 'usenet' }); + const torrentIndexers = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexerSchema, + selectIndexerSchema +}; + +class AddIndexerModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexerSchema(); + } + + // + // Listeners + + onIndexerSelect = ({ implementation, name }) => { + this.props.selectIndexerSchema({ implementation, presetName: name }); + this.props.onModalClose({ indexerSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddIndexerModalContentConnector.propTypes = { + fetchIndexerSchema: PropTypes.func.isRequired, + selectIndexerSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js new file mode 100644 index 000000000..ddea8b043 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddIndexerPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddIndexerPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddIndexerPresetMenuItem; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js new file mode 100644 index 000000000..d7401b95f --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditIndexerModalContentConnector from './EditIndexerModalContentConnector'; + +function EditIndexerModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditIndexerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditIndexerModal; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js new file mode 100644 index 000000000..ec0b7586e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestIndexer, cancelSaveIndexer } from 'Store/Actions/settingsActions'; +import EditIndexerModal from './EditIndexerModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.indexers'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestIndexer() { + dispatch(cancelTestIndexer({ section })); + }, + + dispatchCancelSaveIndexer() { + dispatch(cancelSaveIndexer({ section })); + } + }; +} + +class EditIndexerModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestIndexer(); + this.props.dispatchCancelSaveIndexer(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestIndexer, + dispatchCancelSaveIndexer, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditIndexerModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestIndexer: PropTypes.func.isRequired, + dispatchCancelSaveIndexer: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditIndexerModalConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js new file mode 100644 index 000000000..de81fa3bd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -0,0 +1,179 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditIndexerModalContent.css'; + +function EditIndexerModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteIndexerPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + enableRss, + enableSearch, + supportsRss, + supportsSearch, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Indexer - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new indexer, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + Enable RSS + + + + + + Enable Search + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); +} + +EditIndexerModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteIndexerPress: PropTypes.func +}; + +export default EditIndexerModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js new file mode 100644 index 000000000..f993d2796 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions'; +import EditIndexerModalContent from './EditIndexerModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('indexers'), + (advancedSettings, indexer) => { + return { + advancedSettings, + ...indexer + }; + } + ); +} + +const mapDispatchToProps = { + setIndexerValue, + setIndexerFieldValue, + saveIndexer, + testIndexer +}; + +class EditIndexerModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setIndexerValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setIndexerFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveIndexer({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testIndexer({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditIndexerModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setIndexerValue: PropTypes.func.isRequired, + setIndexerFieldValue: PropTypes.func.isRequired, + saveIndexer: PropTypes.func.isRequired, + testIndexer: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.css b/frontend/src/Settings/Indexers/Indexers/Indexer.css new file mode 100644 index 000000000..d8e1a731e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.css @@ -0,0 +1,19 @@ +.indexer { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js new file mode 100644 index 000000000..7974e11d0 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexer.css'; + +class Indexer extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: false + }; + } + + // + // Listeners + + onEditIndexerPress = () => { + this.setState({ isEditIndexerModalOpen: true }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + onDeleteIndexerPress = () => { + this.setState({ + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: true + }); + } + + onDeleteIndexerModalClose= () => { + this.setState({ isDeleteIndexerModalOpen: false }); + } + + onConfirmDeleteIndexer = () => { + this.props.onConfirmDeleteIndexer(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enableRss, + enableSearch, + supportsRss, + supportsSearch + } = this.props; + + return ( + +
+ {name} +
+ +
+ + { + supportsRss && enableRss && + + } + + { + supportsSearch && enableSearch && + + } + + { + !enableRss && !enableSearch && + + } +
+ + + + +
+ ); + } +} + +Indexer.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enableRss: PropTypes.bool.isRequired, + enableSearch: PropTypes.bool.isRequired, + supportsRss: PropTypes.bool.isRequired, + supportsSearch: PropTypes.bool.isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexer; diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.css b/frontend/src/Settings/Indexers/Indexers/Indexers.css new file mode 100644 index 000000000..ec8cb2891 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.css @@ -0,0 +1,20 @@ +.indexers { + display: flex; + flex-wrap: wrap; +} + +.addIndexer { + composes: indexer from './Indexer.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.js b/frontend/src/Settings/Indexers/Indexers/Indexers.js new file mode 100644 index 000000000..f5fea9aac --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Indexer from './Indexer'; +import AddIndexerModal from './AddIndexerModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexers.css'; + +class Indexers extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: false + }; + } + + // + // Listeners + + onAddIndexerPress = () => { + this.setState({ isAddIndexerModalOpen: true }); + } + + onAddIndexerModalClose = ({ indexerSelected = false } = {}) => { + this.setState({ + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: indexerSelected + }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteIndexer, + ...otherProps + } = this.props; + + const { + isAddIndexerModalOpen, + isEditIndexerModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +Indexers.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexers; diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js new file mode 100644 index 000000000..415dae32b --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers, deleteIndexer } from 'Store/Actions/settingsActions'; +import Indexers from './Indexers'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + return { + ...indexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexers, + deleteIndexer +}; + +class IndexersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexers(); + } + + // + // Listeners + + onConfirmDeleteIndexer = (id) => { + this.props.deleteIndexer({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexersConnector.propTypes = { + fetchIndexers: PropTypes.func.isRequired, + deleteIndexer: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexersConnector); diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js new file mode 100644 index 000000000..b7ac3bbf6 --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js @@ -0,0 +1,112 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function IndexerOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load indexer options
+ } + + { + hasSettings && !isFetching && !error && +
+ + Minimum Age + + + + + + Retention + + + + + + Maximum Size + + + + + + RSS Sync Interval + + + +
+ } +
+ ); +} + +IndexerOptions.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default IndexerOptions; diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js new file mode 100644 index 000000000..28d07c77e --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchIndexerOptions, setIndexerOptionsValue, saveIndexerOptions } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import IndexerOptions from './IndexerOptions'; + +const SECTION = 'indexerOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexerOptions: fetchIndexerOptions, + dispatchSetIndexerOptionsValue: setIndexerOptionsValue, + dispatchSaveIndexerOptions: saveIndexerOptions, + dispatchClearPendingChanges: clearPendingChanges +}; + +class IndexerOptionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchIndexerOptions, + dispatchSaveIndexerOptions, + onChildMounted + } = this.props; + + dispatchFetchIndexerOptions(); + onChildMounted(dispatchSaveIndexerOptions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetIndexerOptionsValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexerOptionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchIndexerOptions: PropTypes.func.isRequired, + dispatchSetIndexerOptionsValue: PropTypes.func.isRequired, + dispatchSaveIndexerOptions: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerOptionsConnector); diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js new file mode 100644 index 000000000..31c36ea6a --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditRestrictionModalContentConnector from './EditRestrictionModalContentConnector'; + +function EditRestrictionModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditRestrictionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditRestrictionModal; diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js new file mode 100644 index 000000000..0089d153e --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditRestrictionModal from './EditRestrictionModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditRestrictionModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.restrictions' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRestrictionModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(EditRestrictionModalConnector); diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js new file mode 100644 index 000000000..37f8cd760 --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js @@ -0,0 +1,126 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditRestrictionModalContent.css'; + +function EditRestrictionModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onModalClose, + onSavePress, + onDeleteRestrictionPress, + ...otherProps + } = props; + + const { + id, + required, + ignored, + tags + } = item; + + return ( + + + {id ? 'Edit Restriction' : 'Add Restriction'} + + + +
+ + Must Contain + + + + + + Must Not Contain + + + + + + Tags + + + +
+
+ + { + id && + + } + + + + + Save + + +
+ ); +} + +EditRestrictionModalContent.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteRestrictionPress: PropTypes.func +}; + +export default EditRestrictionModalContent; diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js new file mode 100644 index 000000000..322b0a8d9 --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js @@ -0,0 +1,111 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setRestrictionValue, saveRestriction } from 'Store/Actions/settingsActions'; +import EditRestrictionModalContent from './EditRestrictionModalContent'; + +const newRestriction = { + required: '', + ignored: '', + tags: [] +}; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.restrictions, + (id, restrictions) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = restrictions; + + const profile = id ? _.find(items, { id }) : newRestriction; + const settings = selectSettings(profile, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setRestrictionValue, + saveRestriction +}; + +class EditRestrictionModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newRestriction).forEach((name) => { + this.props.setRestrictionValue({ + name, + value: newRestriction[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setRestrictionValue({ name, value }); + } + + onSavePress = () => { + this.props.saveRestriction({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRestrictionModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setRestrictionValue: PropTypes.func.isRequired, + saveRestriction: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditRestrictionModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Restrictions/Restriction.css b/frontend/src/Settings/Indexers/Restrictions/Restriction.css new file mode 100644 index 000000000..0e84466f9 --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/Restriction.css @@ -0,0 +1,11 @@ +.restriction { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Indexers/Restrictions/Restriction.js b/frontend/src/Settings/Indexers/Restrictions/Restriction.js new file mode 100644 index 000000000..07b7feece --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/Restriction.js @@ -0,0 +1,148 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import TagList from 'Components/TagList'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditRestrictionModalConnector from './EditRestrictionModalConnector'; +import styles from './Restriction.css'; + +class Restriction extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditRestrictionModalOpen: false, + isDeleteRestrictionModalOpen: false + }; + } + + // + // Listeners + + onEditRestrictionPress = () => { + this.setState({ isEditRestrictionModalOpen: true }); + } + + onEditRestrictionModalClose = () => { + this.setState({ isEditRestrictionModalOpen: false }); + } + + onDeleteRestrictionPress = () => { + this.setState({ + isEditRestrictionModalOpen: false, + isDeleteRestrictionModalOpen: true + }); + } + + onDeleteRestrictionModalClose= () => { + this.setState({ isDeleteRestrictionModalOpen: false }); + } + + onConfirmDeleteRestriction = () => { + this.props.onConfirmDeleteRestriction(this.props.id); + } + + // + // Render + + render() { + const { + id, + required, + ignored, + tags, + tagList + } = this.props; + + return ( + +
+ { + split(required).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + split(ignored).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ + + + + + +
+ ); + } +} + +Restriction.propTypes = { + id: PropTypes.number.isRequired, + required: PropTypes.string.isRequired, + ignored: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteRestriction: PropTypes.func.isRequired +}; + +Restriction.defaultProps = { + required: '', + ignored: '' +}; + +export default Restriction; diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css b/frontend/src/Settings/Indexers/Restrictions/Restrictions.css new file mode 100644 index 000000000..904a66a57 --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/Restrictions.css @@ -0,0 +1,20 @@ +.restrictions { + display: flex; + flex-wrap: wrap; +} + +.addRestriction { + composes: restriction from './Restriction.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.js b/frontend/src/Settings/Indexers/Restrictions/Restrictions.js new file mode 100644 index 000000000..3c9493b39 --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/Restrictions.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Restriction from './Restriction'; +import EditRestrictionModalConnector from './EditRestrictionModalConnector'; +import styles from './Restrictions.css'; + +class Restrictions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddRestrictionModalOpen: false + }; + } + + // + // Listeners + + onAddRestrictionPress = () => { + this.setState({ isAddRestrictionModalOpen: true }); + } + + onAddRestrictionModalClose = () => { + this.setState({ isAddRestrictionModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + tagList, + onConfirmDeleteRestriction, + ...otherProps + } = this.props; + + return ( +
+ +
+ +
+ +
+
+ + { + items.map((item) => { + return ( + + ); + }) + } +
+ + +
+
+ ); + } +} + +Restrictions.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteRestriction: PropTypes.func.isRequired +}; + +export default Restrictions; diff --git a/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js new file mode 100644 index 000000000..c53c05de2 --- /dev/null +++ b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRestrictions, deleteRestriction } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import Restrictions from './Restrictions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.restrictions, + createTagsSelector(), + (restrictions, tagList) => { + return { + ...restrictions, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchRestrictions, + deleteRestriction +}; + +class RestrictionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchRestrictions(); + } + + // + // Listeners + + onConfirmDeleteRestriction = (id) => { + this.props.deleteRestriction({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RestrictionsConnector.propTypes = { + fetchRestrictions: PropTypes.func.isRequired, + deleteRestriction: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RestrictionsConnector); diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js new file mode 100644 index 000000000..7e892f460 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -0,0 +1,393 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import RootFoldersConnector from 'RootFolder/RootFoldersConnector'; +import NamingConnector from './Naming/NamingConnector'; + +const rescanAfterRefreshOptions = [ + { key: 'always', value: 'Always' }, + { key: 'afterManual', value: 'After Manual Refresh' }, + { key: 'never', value: 'Never' } +]; + +const fileDateOptions = [ + { key: 'none', value: 'None' }, + { key: 'cinemas', value: 'In Cinemas Date' }, + { key: 'release', value: 'Physical Release Date' } +]; + +class MediaManagement extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + isMono, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + + + { + isFetching && +
+ +
+ } + + { + !isFetching && error && +
+
Unable to load Media Management settings
+
+ } + + { + hasSettings && !isFetching && !error && +
+ { + advancedSettings && +
+ + Create empty movie folders + + + + + + Delete empty folders + + + +
+ } + + { + advancedSettings && +
+ { + isMono && + + Skip Free Space Check + + + + } + + + Use Hardlinks instead of Copy + + + + + + Import Extra Files + + + + + { + settings.importExtraFiles.value && + + Import Extra Files + + + + } +
+ } + +
+ + Ignore Deleted Movies + + + + + + Download Propers + + + + + + Analyse video files + + + + + + Rescan Movie Folder after Refresh + + + + + + Change File Date + + + + + + Recycling Bin + + + +
+ + { + advancedSettings && isMono && +
+ + Set Permissions + + + + + + File chmod mode + + + + + + Folder chmod mode + + + + + + chown User + + + + + + chown Group + + + +
+ } +
+ } + +
+ +
+
+
+ ); + } + +} + +MediaManagement.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + isMono: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default MediaManagement; diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js new file mode 100644 index 000000000..6412b6cd2 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js @@ -0,0 +1,86 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchMediaManagementSettings, setMediaManagementSettingsValue, saveMediaManagementSettings, saveNamingSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import MediaManagement from './MediaManagement'; + +const SECTION = 'mediaManagement'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.naming, + createSettingsSectionSelector(SECTION), + createSystemStatusSelector(), + (advancedSettings, namingSettings, sectionSettings, systemStatus) => { + return { + advancedSettings, + ...sectionSettings, + hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges, + isMono: systemStatus.isMono + }; + } + ); +} + +const mapDispatchToProps = { + fetchMediaManagementSettings, + setMediaManagementSettingsValue, + saveMediaManagementSettings, + saveNamingSettings, + clearPendingChanges +}; + +class MediaManagementConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMediaManagementSettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: `settings.${SECTION}` }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMediaManagementSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMediaManagementSettings(); + this.props.saveNamingSettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MediaManagementConnector.propTypes = { + fetchMediaManagementSettings: PropTypes.func.isRequired, + setMediaManagementSettingsValue: PropTypes.func.isRequired, + saveMediaManagementSettings: PropTypes.func.isRequired, + saveNamingSettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css new file mode 100644 index 000000000..da27e292e --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css @@ -0,0 +1,5 @@ +.namingInput { + composes: input from 'Components/Form/TextInput.css'; + + font-family: $monoSpaceFontFamily; +} diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js new file mode 100644 index 000000000..57886934a --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -0,0 +1,202 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import NamingModal from './NamingModal'; +import styles from './Naming.css'; + +class Naming extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNamingModalOpen: false, + namingModalOptions: null + }; + } + + // + // Listeners + + onStandardNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'standardMovieFormat', + additional: true + } + }); + } + + onMovieFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'movieFolderFormat' + } + }); + } + + onNamingModalClose = () => { + this.setState({ isNamingModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + examples, + examplesPopulated, + onInputChange + } = this.props; + + const { + isNamingModalOpen, + namingModalOptions + } = this.state; + + const renameEpisodes = hasSettings && settings.renameEpisodes.value; + + const standardMovieFormatHelpTexts = []; + const standardMovieFormatErrors = []; + const movieFolderFormatHelpTexts = []; + const movieFolderFormatErrors = []; + + if (examplesPopulated) { + if (examples.movieExample) { + standardMovieFormatHelpTexts.push(`Movie: ${examples.movieExample}`); + } else { + standardMovieFormatErrors.push({ message: 'Movie: Invalid Format' }); + } + + if (examples.movieFolderExample) { + movieFolderFormatHelpTexts.push(`Example: ${examples.movieFolderExample}`); + } else { + movieFolderFormatErrors.push({ message: 'Invalid Format' }); + } + } + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load Naming settings
+ } + + { + hasSettings && !isFetching && !error && +
+ + Rename Movies + + + + + + Replace Illegal Characters + + + + + { + renameEpisodes && +
+ + Standard Movie Format + + ?} + onChange={onInputChange} + {...settings.standardMovieFormat} + helpTexts={standardMovieFormatHelpTexts} + errors={[...standardMovieFormatErrors, ...settings.standardMovieFormat.errors]} + /> + +
+ } + + + Movie Folder Format + + ?} + onChange={onInputChange} + {...settings.movieFolderFormat} + helpTexts={['Used when adding a new movie or moving movies via the editor', ...movieFolderFormatHelpTexts]} + errors={[...movieFolderFormatErrors, ...settings.movieFolderFormat.errors]} + /> + + + { + namingModalOptions && + + } + + } +
+ ); + } + +} + +Naming.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + examples: PropTypes.object.isRequired, + examplesPopulated: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default Naming; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js new file mode 100644 index 000000000..d8210317e --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js @@ -0,0 +1,97 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchNamingSettings, setNamingSettingsValue, fetchNamingExamples } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import Naming from './Naming'; + +const SECTION = 'naming'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.namingExamples, + createSettingsSectionSelector(SECTION), + (advancedSettings, examples, sectionSettings) => { + return { + advancedSettings, + examples: examples.item, + examplesPopulated: !_.isEmpty(examples.item), + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + fetchNamingSettings, + setNamingSettingsValue, + fetchNamingExamples, + clearPendingChanges +}; + +class NamingConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._namingExampleTimeout = null; + } + + componentDidMount() { + this.props.fetchNamingSettings(); + this.props.fetchNamingExamples(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Control + + _fetchNamingExamples = () => { + this.props.fetchNamingExamples(); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setNamingSettingsValue({ name, value }); + + if (this._namingExampleTimeout) { + clearTimeout(this._namingExampleTimeout); + } + + this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NamingConnector.propTypes = { + fetchNamingSettings: PropTypes.func.isRequired, + setNamingSettingsValue: PropTypes.func.isRequired, + fetchNamingExamples: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css new file mode 100644 index 000000000..de6a54e7f --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css @@ -0,0 +1,18 @@ +.groups { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.namingSelectContainer { + display: flex; + justify-content: flex-end; +} + +.namingSelect { + composes: select from 'Components/Form/SelectInput.css'; + + margin-left: 10px; + width: 200px; +} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js new file mode 100644 index 000000000..7f65f9e76 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -0,0 +1,549 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import NamingOption from './NamingOption'; +import styles from './NamingModal.css'; + +class NamingModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._selectionStart = null; + this._selectionEnd = null; + + this.state = { + separator: ' ', + case: 'title' + }; + } + + // + // Listeners + + onTokenSeparatorChange = (event) => { + this.setState({ separator: event.value }); + } + + onTokenCaseChange = (event) => { + this.setState({ case: event.value }); + } + + onInputSelectionChange = (selectionStart, selectionEnd) => { + this._selectionStart = selectionStart; + this._selectionEnd = selectionEnd; + } + + onOptionPress = ({ isFullFilename, tokenValue }) => { + const { + name, + value, + onInputChange + } = this.props; + + const selectionStart = this._selectionStart; + const selectionEnd = this._selectionEnd; + + if (isFullFilename) { + onInputChange({ name, value: tokenValue }); + } else if (selectionStart == null) { + onInputChange({ + name, + value: `${value}${tokenValue}` + }); + } else { + const start = value.substring(0, selectionStart); + const end = value.substring(selectionEnd); + const newValue = `${start}${tokenValue}${end}`; + + onInputChange({ name, value: newValue }); + this._selectionStart = newValue.length - 1; + this._selectionEnd = newValue.length - 1; + } + } + + // + // Render + + render() { + const { + name, + value, + isOpen, + advancedSettings, + season, + episode, + daily, + anime, + additional, + onInputChange, + onModalClose + } = this.props; + + const { + separator: tokenSeparator, + case: tokenCase + } = this.state; + + const separatorOptions = [ + { key: ' ', value: 'Space ( )' }, + { key: '.', value: 'Period (.)' }, + { key: '_', value: 'Underscore (_)' }, + { key: '-', value: 'Dash (-)' } + ]; + + const caseOptions = [ + { key: 'title', value: 'Default Case' }, + { key: 'lower', value: 'Lower Case' }, + { key: 'upper', value: 'Upper Case' } + ]; + + const fileNameTokens = [ + { + token: '{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}', + example: 'Series Title (2010) - S01E01 - Episode Title HDTV-720p Proper' + }, + { + token: '{Series Title} - {season:0}x{episode:00} - {Episode Title} {Quality Full}', + example: 'Series Title (2010) - 1x01 - Episode Title HDTV-720p Proper' + }, + { + token: '{Series.Title}.S{season:00}E{episode:00}.{EpisodeClean.Title}.{Quality.Full}', + example: 'Series.Title.(2010).S01E01.Episode.Title.HDTV-720p' + } + ]; + + const seriesTokens = [ + { token: '{Series Title}', example: 'Series Title!' }, + { token: '{Series CleanTitle}', example: 'Series Title' }, + { token: '{Series CleanTitleYear}', example: 'Series Title 2010' }, + { token: '{Series TitleThe}', example: 'Series Title, The' }, + { token: '{Series TitleTheYear}', example: 'Series Title, The (2010)' }, + { token: '{Series TitleYear}', example: 'Series Title (2010)' } + ]; + + const seriesIdTokens = [ + { token: '{ImdbId}', example: 'tt12345' }, + { token: '{TvdbId}', example: '12345' }, + { token: '{TvMazeId}', example: '54321' } + ]; + + const seasonTokens = [ + { token: '{season:0}', example: '1' }, + { token: '{season:00}', example: '01' } + ]; + + const episodeTokens = [ + { token: '{episode:0}', example: '1' }, + { token: '{episode:00}', example: '01' } + ]; + + const airDateTokens = [ + { token: '{Air-Date}', example: '2016-03-20' }, + { token: '{Air Date}', example: '2016 03 20' } + ]; + + const absoluteTokens = [ + { token: '{absolute:0}', example: '1' }, + { token: '{absolute:00}', example: '01' }, + { token: '{absolute:000}', example: '001' } + ]; + + const episodeTitleTokens = [ + { token: '{Episode Title}', example: 'Episode Title' }, + { token: '{Episode CleanTitle}', example: 'Episode Title' } + ]; + + const qualityTokens = [ + { token: '{Quality Full}', example: 'HDTV 720p Proper' }, + { token: '{Quality Title}', example: 'HDTV 720p' } + ]; + + const mediaInfoTokens = [ + { token: '{MediaInfo Simple}', example: 'x264 DTS' }, + { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' }, + { token: '{MediaInfo VideoCodec}', example: 'x264' }, + { token: '{MediaInfo AudioFormat}', example: 'DTS' }, + { token: '{MediaInfo AudioChannels}', example: '5.1' } + ]; + + const releaseGroupTokens = [ + { token: '{Release Group}', example: 'Rls Grp' } + ]; + + const originalTokens = [ + { token: '{Original Title}', example: 'Series.Title.S01E01.HDTV.x264-EVOLVE' }, + { token: '{Original Filename}', example: 'series.title.s01e01.hdtv.x264-EVOLVE' } + ]; + + return ( + + + + File Name Tokens + + + +
+ + + +
+ + { + !advancedSettings && +
+
+ { + fileNameTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + +
+
+ { + seriesTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + seriesIdTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ + { + season && +
+
+ { + seasonTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + + { + episode && +
+
+
+ { + episodeTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ + { + daily && +
+
+ { + airDateTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + + { + anime && +
+
+ { + absoluteTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } +
+ } + + { + additional && +
+
+
+ { + episodeTitleTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + qualityTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + mediaInfoTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + releaseGroupTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + originalTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+
+ } +
+ + + + + +
+
+ ); + } +} + +NamingModal.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + advancedSettings: PropTypes.bool.isRequired, + season: PropTypes.bool.isRequired, + episode: PropTypes.bool.isRequired, + daily: PropTypes.bool.isRequired, + anime: PropTypes.bool.isRequired, + additional: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +NamingModal.defaultProps = { + season: false, + episode: false, + daily: false, + anime: false, + additional: false +}; + +export default NamingModal; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css new file mode 100644 index 000000000..299c98936 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css @@ -0,0 +1,66 @@ +.option { + display: flex; + align-items: center; + flex-wrap: wrap; + margin: 3px; + border: 1px solid $borderColor; + + &:hover { + .token { + background-color: #ddd; + } + + .example { + background-color: #ccc; + } + } +} + +.small { + width: 420px; +} + +.large { + width: 100%; +} + +.token { + flex: 0 0 50%; + padding: 6px 16px; + background-color: #eee; + font-family: $monoSpaceFontFamily; +} + +.example { + flex: 0 0 50%; + padding: 6px 16px; + background-color: #ddd; +} + +.lower { + text-transform: lowercase; +} + +.upper { + text-transform: uppercase; +} + +.isFullFilename { + .token, + .example { + flex: 1 0 auto; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .option.small { + width: 100%; + } +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .token, + .example { + flex: 1 0 auto; + } +} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js new file mode 100644 index 000000000..269266a5f --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { sizes } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import styles from './NamingOption.css'; + +class NamingOption extends Component { + + // + // Listeners + + onPress = () => { + const { + token, + tokenSeparator, + tokenCase, + isFullFilename, + onPress + } = this.props; + + let tokenValue = token; + + tokenValue = tokenValue.replace(/ /g, tokenSeparator); + + if (tokenCase === 'lower') { + tokenValue = token.toLowerCase(); + } else if (tokenCase === 'upper') { + tokenValue = token.toUpperCase(); + } + + onPress({ isFullFilename, tokenValue }); + } + + // + // Render + render() { + const { + token, + tokenSeparator, + example, + tokenCase, + isFullFilename, + size + } = this.props; + + return ( + +
+ {token.replace(/ /g, tokenSeparator)} +
+ +
+ {example.replace(/ /g, tokenSeparator)} +
+ + ); + } +} + +NamingOption.propTypes = { + token: PropTypes.string.isRequired, + example: PropTypes.string.isRequired, + tokenSeparator: PropTypes.string.isRequired, + tokenCase: PropTypes.string.isRequired, + isFullFilename: PropTypes.bool.isRequired, + size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]), + onPress: PropTypes.func.isRequired +}; + +NamingOption.defaultProps = { + size: sizes.SMALL, + isFullFilename: false +}; + +export default NamingOption; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js new file mode 100644 index 000000000..24c0237cd --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditMetadataModalContentConnector from './EditMetadataModalContentConnector'; + +function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditMetadataModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditMetadataModal; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js new file mode 100644 index 000000000..1a38beb4a --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMetadataModal from './EditMetadataModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.metadata'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + } + }; +} + +class EditMetadataModalConnector extends Component { + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges({ section: 'metadata' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js new file mode 100644 index 000000000..96bbb4b83 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; + +function EditMetadataModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + ...otherProps + } = props; + + const { + name, + enable, + fields + } = item; + + return ( + + + Edit {name.value} Metadata + + + +
+ + Enable + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + +
+ + + + + + Save + + +
+ ); +} + +EditMetadataModalContent.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteMetadataPress: PropTypes.func +}; + +export default EditMetadataModalContent; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js new file mode 100644 index 000000000..2cd7636a0 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js @@ -0,0 +1,93 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setMetadataValue, setMetadataFieldValue, saveMetadata } from 'Store/Actions/settingsActions'; +import EditMetadataModalContent from './EditMetadataModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.metadata, + (id, metadata) => { + const { + isSaving, + saveError, + pendingChanges, + items + } = metadata; + + const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError); + + return { + id, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setMetadataValue, + setMetadataFieldValue, + saveMetadata +}; + +class EditMetadataModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMetadataValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setMetadataFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMetadata({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setMetadataValue: PropTypes.func.isRequired, + setMetadataFieldValue: PropTypes.func.isRequired, + saveMetadata: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css new file mode 100644 index 000000000..31507ee23 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css @@ -0,0 +1,15 @@ +.metadata { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.section { + margin-top: 10px; +} diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js new file mode 100644 index 000000000..eba01463c --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import EditMetadataModalConnector from './EditMetadataModalConnector'; +import styles from './Metadata.css'; + +class Metadata extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMetadataModalOpen: false + }; + } + + // + // Listeners + + onEditMetadataPress = () => { + this.setState({ isEditMetadataModalOpen: true }); + } + + onEditMetadataModalClose = () => { + this.setState({ isEditMetadataModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + name, + enable, + fields + } = this.props; + + const metadataFields = []; + const imageFields = []; + + fields.forEach((field) => { + if (field.section === 'metadata') { + metadataFields.push(field); + } else { + imageFields.push(field); + } + }); + + return ( + +
+ {name} +
+ +
+ { + enable ? + : + + } +
+ + { + enable && !!metadataFields.length && +
+
+ Metadata +
+ + { + metadataFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
+ } + + { + enable && !!imageFields.length && +
+
+ Images +
+ + { + imageFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
+ } + + +
+ ); + } +} + +Metadata.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Metadata; diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css b/frontend/src/Settings/Metadata/Metadata/Metadatas.css new file mode 100644 index 000000000..fb1bd6080 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css @@ -0,0 +1,4 @@ +.metadatas { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js new file mode 100644 index 000000000..8659c5799 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Metadata from './Metadata'; +import styles from './Metadatas.css'; + +function Metadatas(props) { + const { + items, + ...otherProps + } = props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } +
+
+
+ ); +} + +Metadatas.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Metadatas; diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js new file mode 100644 index 000000000..fb7153950 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchMetadata } from 'Store/Actions/settingsActions'; +import Metadatas from './Metadatas'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.metadata, + (metadata) => { + return { + ...metadata + }; + } + ); +} + +const mapDispatchToProps = { + fetchMetadata +}; + +class MetadatasConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMetadata(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadatasConnector.propTypes = { + fetchMetadata: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js new file mode 100644 index 000000000..001936ab7 --- /dev/null +++ b/frontend/src/Settings/Metadata/MetadataSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import MetadatasConnector from './Metadata/MetadatasConnector'; + +function MetadataSettings() { + return ( + + + + + + + + ); +} + +export default MetadataSettings; diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.css b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.css new file mode 100644 index 000000000..0dd9e22db --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.css @@ -0,0 +1,44 @@ +.netImport { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.js new file mode 100644 index 000000000..ea05a136b --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddNetImportPresetMenuItem from './AddNetImportPresetMenuItem'; +import styles from './AddNetImportItem.css'; + +class AddNetImportItem extends Component { + + // + // Listeners + + onNetImportSelect = () => { + const { + implementation + } = this.props; + + this.props.onNetImportSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onNetImportSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddNetImportItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onNetImportSelect: PropTypes.func.isRequired +}; + +export default AddNetImportItem; diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModal.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportModal.js new file mode 100644 index 000000000..4f1921e5b --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddNetImportModalContentConnector from './AddNetImportModalContentConnector'; + +function AddNetImportModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddNetImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNetImportModal; diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.css b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.css new file mode 100644 index 000000000..6f57c542e --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.css @@ -0,0 +1,5 @@ +.netImports { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.js new file mode 100644 index 000000000..7824a554e --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContent.js @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddNetImportItem from './AddNetImportItem'; +import styles from './AddNetImportModalContent.css'; + +class AddNetImportModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + netImports, + onNetImportSelect, + onModalClose + } = this.props; + + return ( + + + Add List + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new list, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Radarr supports any RSS movie lists as well as the one stated below.
+
For more information on the individual netImports, clink on the info buttons.
+
+ +
+
+ { + netImports.map((netImport) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +AddNetImportModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + netImports: PropTypes.arrayOf(PropTypes.object).isRequired, + onNetImportSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNetImportModalContent; diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContentConnector.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContentConnector.js new file mode 100644 index 000000000..39e43b366 --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportModalContentConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNetImportSchema, selectNetImportSchema } from 'Store/Actions/settingsActions'; +import AddNetImportModalContent from './AddNetImportModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.netImports, + (netImports) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = netImports; + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + netImports: schema + }; + } + ); +} + +const mapDispatchToProps = { + fetchNetImportSchema, + selectNetImportSchema +}; + +class AddNetImportModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNetImportSchema(); + } + + // + // Listeners + + onNetImportSelect = ({ implementation, name }) => { + this.props.selectNetImportSchema({ implementation, presetName: name }); + this.props.onModalClose({ netImportSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNetImportModalContentConnector.propTypes = { + fetchNetImportSchema: PropTypes.func.isRequired, + selectNetImportSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNetImportModalContentConnector); diff --git a/frontend/src/Settings/NetImport/NetImport/AddNetImportPresetMenuItem.js b/frontend/src/Settings/NetImport/NetImport/AddNetImportPresetMenuItem.js new file mode 100644 index 000000000..75f6279cb --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/AddNetImportPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddNetImportPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddNetImportPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddNetImportPresetMenuItem; diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModal.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModal.js new file mode 100644 index 000000000..0fd411c5f --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditNetImportModalContentConnector from './EditNetImportModalContentConnector'; + +function EditNetImportModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditNetImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditNetImportModal; diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalConnector.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalConnector.js new file mode 100644 index 000000000..18c567af3 --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestNetImport, cancelSaveNetImport } from 'Store/Actions/settingsActions'; +import EditNetImportModal from './EditNetImportModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.netImports'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestNetImport() { + dispatch(cancelTestNetImport({ section })); + }, + + dispatchCancelSaveNetImport() { + dispatch(cancelSaveNetImport({ section })); + } + }; +} + +class EditNetImportModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestNetImport(); + this.props.dispatchCancelSaveNetImport(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestNetImport, + dispatchCancelSaveNetImport, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditNetImportModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestNetImport: PropTypes.func.isRequired, + dispatchCancelSaveNetImport: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditNetImportModalConnector); diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.css b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.js new file mode 100644 index 000000000..39d0233c2 --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContent.js @@ -0,0 +1,211 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditNetImportModalContent.css'; + +function EditNetImportModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteNetImportPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + enabled, + enableAuto, + shouldMonitor, + qualityProfileId, + rootFolderPath, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} List - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new list, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + Enable + + + + + + Enable Automatic Add + + + + + + Add Movies Monitored + + + + + + Quality Profile + + + + + + Folder + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); +} + +EditNetImportModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteNetImportPress: PropTypes.func +}; + +export default EditNetImportModalContent; diff --git a/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContentConnector.js b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContentConnector.js new file mode 100644 index 000000000..374d9f216 --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/EditNetImportModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setNetImportValue, setNetImportFieldValue, saveNetImport, testNetImport } from 'Store/Actions/settingsActions'; +import EditNetImportModalContent from './EditNetImportModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('netImports'), + (advancedSettings, netImport) => { + return { + advancedSettings, + ...netImport + }; + } + ); +} + +const mapDispatchToProps = { + setNetImportValue, + setNetImportFieldValue, + saveNetImport, + testNetImport +}; + +class EditNetImportModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setNetImportValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setNetImportFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveNetImport({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testNetImport({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditNetImportModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setNetImportValue: PropTypes.func.isRequired, + setNetImportFieldValue: PropTypes.func.isRequired, + saveNetImport: PropTypes.func.isRequired, + testNetImport: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditNetImportModalContentConnector); diff --git a/frontend/src/Settings/NetImport/NetImport/NetImport.css b/frontend/src/Settings/NetImport/NetImport/NetImport.css new file mode 100644 index 000000000..92b99ec3b --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/NetImport.css @@ -0,0 +1,19 @@ +.netImport { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/NetImport/NetImport/NetImport.js b/frontend/src/Settings/NetImport/NetImport/NetImport.js new file mode 100644 index 000000000..0969d72c7 --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/NetImport.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditNetImportModalConnector from './EditNetImportModalConnector'; +import styles from './NetImport.css'; + +class NetImport extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditNetImportModalOpen: false, + isDeleteNetImportModalOpen: false + }; + } + + // + // Listeners + + onEditNetImportPress = () => { + this.setState({ isEditNetImportModalOpen: true }); + } + + onEditNetImportModalClose = () => { + this.setState({ isEditNetImportModalOpen: false }); + } + + onDeleteNetImportPress = () => { + this.setState({ + isEditNetImportModalOpen: false, + isDeleteNetImportModalOpen: true + }); + } + + onDeleteNetImportModalClose= () => { + this.setState({ isDeleteNetImportModalOpen: false }); + } + + onConfirmDeleteNetImport = () => { + this.props.onConfirmDeleteNetImport(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enabled, + enableAuto + } = this.props; + + return ( + +
+ {name} +
+ +
+ + { + enabled && + + } + + { + enableAuto && + + } + + { + !enabled && !enableAuto && + + } +
+ + + + +
+ ); + } +} + +NetImport.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enabled: PropTypes.bool.isRequired, + enableAuto: PropTypes.bool.isRequired, + onConfirmDeleteNetImport: PropTypes.func.isRequired +}; + +export default NetImport; diff --git a/frontend/src/Settings/NetImport/NetImport/NetImports.css b/frontend/src/Settings/NetImport/NetImport/NetImports.css new file mode 100644 index 000000000..e7d2854a6 --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/NetImports.css @@ -0,0 +1,20 @@ +.netImports { + display: flex; + flex-wrap: wrap; +} + +.addNetImport { + composes: netImport from './NetImport.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/NetImport/NetImport/NetImports.js b/frontend/src/Settings/NetImport/NetImport/NetImports.js new file mode 100644 index 000000000..668f8ccfb --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/NetImports.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import NetImport from './NetImport'; +import AddNetImportModal from './AddNetImportModal'; +import EditNetImportModalConnector from './EditNetImportModalConnector'; +import styles from './NetImports.css'; + +class NetImports extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNetImportModalOpen: false, + isEditNetImportModalOpen: false + }; + } + + // + // Listeners + + onAddNetImportPress = () => { + this.setState({ isAddNetImportModalOpen: true }); + } + + onAddNetImportModalClose = ({ netImportSelected = false } = {}) => { + this.setState({ + isAddNetImportModalOpen: false, + isEditNetImportModalOpen: netImportSelected + }); + } + + onEditNetImportModalClose = () => { + this.setState({ isEditNetImportModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteNetImport, + ...otherProps + } = this.props; + + const { + isAddNetImportModalOpen, + isEditNetImportModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +NetImports.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteNetImport: PropTypes.func.isRequired +}; + +export default NetImports; diff --git a/frontend/src/Settings/NetImport/NetImport/NetImportsConnector.js b/frontend/src/Settings/NetImport/NetImport/NetImportsConnector.js new file mode 100644 index 000000000..5e922033c --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImport/NetImportsConnector.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNetImports, deleteNetImport } from 'Store/Actions/settingsActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import NetImports from './NetImports'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.netImports, + (netImports) => { + return { + ...netImports + }; + } + ); +} + +const mapDispatchToProps = { + fetchNetImports, + deleteNetImport, + fetchRootFolders +}; + +class NetImportsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNetImports(); + this.props.fetchRootFolders(); + } + + // + // Listeners + + onConfirmDeleteNetImport = (id) => { + this.props.deleteNetImport({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NetImportsConnector.propTypes = { + fetchNetImports: PropTypes.func.isRequired, + deleteNetImport: PropTypes.func.isRequired, + fetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NetImportsConnector); diff --git a/frontend/src/Settings/NetImport/NetImportSettings.js b/frontend/src/Settings/NetImport/NetImportSettings.js new file mode 100644 index 000000000..9da0370a8 --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImportSettings.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import NetImportsConnector from './NetImport/NetImportsConnector'; +import NetImportOptionsConnector from './Options/NetImportOptionsConnector'; +// import ImportExclusionsConnector from './ImportExclusions/ImportExclusionsConnector'; + +class NetImportSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // Render + // + + render() { + const { + isTestingAll, + dispatchTestAllNetImport + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + + ); + } +} + +NetImportSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllNetImport: PropTypes.func.isRequired +}; + +export default NetImportSettings; diff --git a/frontend/src/Settings/NetImport/NetImportSettingsConnector.js b/frontend/src/Settings/NetImport/NetImportSettingsConnector.js new file mode 100644 index 000000000..0484dc88f --- /dev/null +++ b/frontend/src/Settings/NetImport/NetImportSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllNetImport } from 'Store/Actions/settingsActions'; +import NetImportSettings from './NetImportSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.netImports.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllNetImport: testAllNetImport +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NetImportSettings); diff --git a/frontend/src/Settings/NetImport/Options/NetImportOptions.js b/frontend/src/Settings/NetImport/Options/NetImportOptions.js new file mode 100644 index 000000000..8b9c15436 --- /dev/null +++ b/frontend/src/Settings/NetImport/Options/NetImportOptions.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function NetImportOptions(props) { + const { + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + const cleanLibraryLevelOptions = [ + { key: 'disabled', value: 'Disabled' }, + { key: 'logOnly', value: 'Log Only' }, + { key: 'keepAndUnmonitor', value: 'Keep and Unmonitor' }, + { key: 'removeAndKeep', value: 'Remove and Keep' }, + { key: 'removeAndDelete', value: 'Remove and Delete' } + ]; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load list options
+ } + + { + hasSettings && !isFetching && !error && +
+ + List Update Interval + + + + + + Clean Library Level + + + +
+ } +
+ ); +} + +NetImportOptions.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default NetImportOptions; diff --git a/frontend/src/Settings/NetImport/Options/NetImportOptionsConnector.js b/frontend/src/Settings/NetImport/Options/NetImportOptionsConnector.js new file mode 100644 index 000000000..eb2d94913 --- /dev/null +++ b/frontend/src/Settings/NetImport/Options/NetImportOptionsConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchNetImportOptions, setNetImportOptionsValue, saveNetImportOptions } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import NetImportOptions from './NetImportOptions'; + +const SECTION = 'netImportOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchNetImportOptions: fetchNetImportOptions, + dispatchSetNetImportOptionsValue: setNetImportOptionsValue, + dispatchSaveNetImportOptions: saveNetImportOptions, + dispatchClearPendingChanges: clearPendingChanges +}; + +class NetImportOptionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchNetImportOptions, + dispatchSaveNetImportOptions, + onChildMounted + } = this.props; + + dispatchFetchNetImportOptions(); + onChildMounted(dispatchSaveNetImportOptions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetNetImportOptionsValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NetImportOptionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchNetImportOptions: PropTypes.func.isRequired, + dispatchSetNetImportOptionsValue: PropTypes.func.isRequired, + dispatchSaveNetImportOptions: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NetImportOptionsConnector); diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js new file mode 100644 index 000000000..c9bed6501 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import NotificationsConnector from './Notifications/NotificationsConnector'; + +function NotificationSettings() { + return ( + + + + + + + + ); +} + +export default NotificationSettings; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css new file mode 100644 index 000000000..e2181150c --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css @@ -0,0 +1,44 @@ +.notification { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js new file mode 100644 index 000000000..6d90961b0 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem'; +import styles from './AddNotificationItem.css'; + +class AddNotificationItem extends Component { + + // + // Listeners + + onNotificationSelect = () => { + const { + implementation + } = this.props; + + this.props.onNotificationSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onNotificationSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +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 ( + + ); + }) + } +
+
+ } +
+ + + +
+ ); + } +} + +AddNotificationModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + schema: PropTypes.arrayOf(PropTypes.object).isRequired, + onNotificationSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNotificationModalContent; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js new file mode 100644 index 000000000..abeb5e2ac --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNotificationSchema, selectNotificationSchema } from 'Store/Actions/settingsActions'; +import AddNotificationModalContent from './AddNotificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.notifications, + (notifications) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = notifications; + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + }; + } + ); +} + +const mapDispatchToProps = { + fetchNotificationSchema, + selectNotificationSchema +}; + +class AddNotificationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNotificationSchema(); + } + + // + // Listeners + + onNotificationSelect = ({ implementation, name }) => { + this.props.selectNotificationSchema({ implementation, presetName: name }); + this.props.onModalClose({ notificationSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNotificationModalContentConnector.propTypes = { + fetchNotificationSchema: PropTypes.func.isRequired, + selectNotificationSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNotificationModalContentConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js new file mode 100644 index 000000000..e4df85b8a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddNotificationPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddNotificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddNotificationPresetMenuItem; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js new file mode 100644 index 000000000..27e41d062 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditNotificationModalContentConnector from './EditNotificationModalContentConnector'; + +function EditNotificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditNotificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditNotificationModal; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js new file mode 100644 index 000000000..e1452d142 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestNotification, cancelSaveNotification } from 'Store/Actions/settingsActions'; +import EditNotificationModal from './EditNotificationModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.notifications'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestNotification() { + dispatch(cancelTestNotification({ section })); + }, + + dispatchCancelSaveNotification() { + dispatch(cancelSaveNotification({ section })); + } + }; +} + +class EditNotificationModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestNotification(); + this.props.dispatchCancelSaveNotification(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestNotification, + dispatchCancelSaveNotification, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditNotificationModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestNotification: PropTypes.func.isRequired, + dispatchCancelSaveNotification: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditNotificationModalConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css new file mode 100644 index 000000000..c73406b57 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from 'Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js new file mode 100644 index 000000000..5c08e6622 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -0,0 +1,235 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditNotificationModalContent.css'; + +function EditNotificationModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteNotificationPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + onGrab, + onDownload, + onUpgrade, + onRename, + supportsOnGrab, + supportsOnDownload, + supportsOnUpgrade, + supportsOnRename, + tags, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Connection - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new notification, please try again.
+ } + + { + !isFetching && !error && +
+ { + !!message && + + {message.value.message} + + } + + + Name + + + + + + On Grab + + + + + + On Import + + + + + { + onDownload.value && + + On Upgrade + + + + } + + + On Rename + + + + + + Tags + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); +} + +EditNotificationModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteNotificationPress: PropTypes.func +}; + +export default EditNotificationModalContent; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js new file mode 100644 index 000000000..104f1897a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setNotificationValue, setNotificationFieldValue, saveNotification, testNotification } from 'Store/Actions/settingsActions'; +import EditNotificationModalContent from './EditNotificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('notifications'), + (advancedSettings, notification) => { + return { + advancedSettings, + ...notification + }; + } + ); +} + +const mapDispatchToProps = { + setNotificationValue, + setNotificationFieldValue, + saveNotification, + testNotification +}; + +class EditNotificationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setNotificationValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setNotificationFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveNotification({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testNotification({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditNotificationModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setNotificationValue: PropTypes.func.isRequired, + setNotificationFieldValue: PropTypes.func.isRequired, + saveNotification: PropTypes.func.isRequired, + testNotification: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditNotificationModalContentConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css b/frontend/src/Settings/Notifications/Notifications/Notification.css new file mode 100644 index 000000000..5a17a4c20 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notification.css @@ -0,0 +1,19 @@ +.notification { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js new file mode 100644 index 000000000..143e5491a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notification.js @@ -0,0 +1,150 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditNotificationModalConnector from './EditNotificationModalConnector'; +import styles from './Notification.css'; + +class Notification extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditNotificationModalOpen: false, + isDeleteNotificationModalOpen: false + }; + } + + // + // Listeners + + onEditNotificationPress = () => { + this.setState({ isEditNotificationModalOpen: true }); + } + + onEditNotificationModalClose = () => { + this.setState({ isEditNotificationModalOpen: false }); + } + + onDeleteNotificationPress = () => { + this.setState({ + isEditNotificationModalOpen: false, + isDeleteNotificationModalOpen: true + }); + } + + onDeleteNotificationModalClose= () => { + this.setState({ isDeleteNotificationModalOpen: false }); + } + + onConfirmDeleteNotification = () => { + this.props.onConfirmDeleteNotification(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + onGrab, + onDownload, + onUpgrade, + onRename, + supportsOnGrab, + supportsOnDownload, + supportsOnUpgrade, + supportsOnRename + } = this.props; + + return ( + +
+ {name} +
+ + { + supportsOnGrab && onGrab && + + } + + { + supportsOnDownload && onDownload && + + } + + { + supportsOnUpgrade && onDownload && onUpgrade && + + } + + { + supportsOnRename && onRename && + + } + + { + !onGrab && !onDownload && !onRename && + + } + + + + +
+ ); + } +} + +Notification.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + onGrab: PropTypes.bool.isRequired, + onDownload: PropTypes.bool.isRequired, + onUpgrade: PropTypes.bool.isRequired, + onRename: PropTypes.bool.isRequired, + supportsOnGrab: PropTypes.bool.isRequired, + supportsOnDownload: PropTypes.bool.isRequired, + supportsOnUpgrade: PropTypes.bool.isRequired, + supportsOnRename: PropTypes.bool.isRequired, + onConfirmDeleteNotification: PropTypes.func.isRequired +}; + +export default Notification; diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css new file mode 100644 index 000000000..26b890e88 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css @@ -0,0 +1,20 @@ +.notifications { + display: flex; + flex-wrap: wrap; +} + +.addNotification { + composes: notification from './Notification.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.js b/frontend/src/Settings/Notifications/Notifications/Notifications.js new file mode 100644 index 000000000..0296c2ed4 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Notification from './Notification'; +import AddNotificationModal from './AddNotificationModal'; +import EditNotificationModalConnector from './EditNotificationModalConnector'; +import styles from './Notifications.css'; + +class Notifications extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNotificationModalOpen: false, + isEditNotificationModalOpen: false + }; + } + + // + // Listeners + + onAddNotificationPress = () => { + this.setState({ isAddNotificationModalOpen: true }); + } + + onAddNotificationModalClose = ({ notificationSelected = false } = {}) => { + this.setState({ + isAddNotificationModalOpen: false, + isEditNotificationModalOpen: notificationSelected + }); + } + + onEditNotificationModalClose = () => { + this.setState({ isEditNotificationModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteNotification, + ...otherProps + } = this.props; + + const { + isAddNotificationModalOpen, + isEditNotificationModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +Notifications.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteNotification: PropTypes.func.isRequired +}; + +export default Notifications; diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js new file mode 100644 index 000000000..b2b5e5166 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNotifications, deleteNotification } from 'Store/Actions/settingsActions'; +import Notifications from './Notifications'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.notifications, + (notifications) => { + return { + ...notifications + }; + } + ); +} + +const mapDispatchToProps = { + fetchNotifications, + deleteNotification +}; + +class NotificationsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNotifications(); + } + + // + // Listeners + + onConfirmDeleteNotification = (id) => { + this.props.deleteNotification({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NotificationsConnector.propTypes = { + fetchNotifications: PropTypes.func.isRequired, + deleteNotification: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NotificationsConnector); diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js new file mode 100644 index 000000000..e3b14e228 --- /dev/null +++ b/frontend/src/Settings/PendingChangesModal.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function PendingChangesModal(props) { + const { + isOpen, + onConfirm, + onCancel + } = props; + + return ( + + + Unsaved Changes + + + You have unsaved changes, are you sure you want to leave this page? + + + + + + + + + + ); +} + +PendingChangesModal.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + kind: PropTypes.oneOf(kinds.all), + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +PendingChangesModal.defaultProps = { + kind: kinds.PRIMARY +}; + +export default PendingChangesModal; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css new file mode 100644 index 000000000..238742efd --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css @@ -0,0 +1,40 @@ +.delayProfile { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.column { + flex: 0 0 200px; +} + +.actions { + display: flex; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.editButton { + width: $dragHandleWidth; + text-align: center; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js new file mode 100644 index 000000000..d72a06467 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import titleCase from 'Utilities/String/titleCase'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import TagList from 'Components/TagList'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditDelayProfileModalConnector from './EditDelayProfileModalConnector'; +import styles from './DelayProfile.css'; + +function getDelay(enabled, delay) { + if (!enabled) { + return '-'; + } + + if (!delay) { + return 'No Delay'; + } + + if (delay === 1) { + return '1 Minute'; + } + + // TODO: use better units of time than just minutes + return `${delay} Minutes`; +} + +class DelayProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditDelayProfileModalOpen: false, + isDeleteDelayProfileModalOpen: false + }; + } + + // + // Listeners + + onEditDelayProfilePress = () => { + this.setState({ isEditDelayProfileModalOpen: true }); + } + + onEditDelayProfileModalClose = () => { + this.setState({ isEditDelayProfileModalOpen: false }); + } + + onDeleteDelayProfilePress = () => { + this.setState({ + isEditDelayProfileModalOpen: false, + isDeleteDelayProfileModalOpen: true + }); + } + + onDeleteDelayProfileModalClose = () => { + this.setState({ isDeleteDelayProfileModalOpen: false }); + } + + onConfirmDeleteDelayProfile = () => { + this.props.onConfirmDeleteDelayProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + enableUsenet, + enableTorrent, + preferredProtocol, + usenetDelay, + torrentDelay, + tags, + tagList, + isDragging, + connectDragSource + } = this.props; + + let preferred = titleCase(preferredProtocol); + + if (!enableUsenet) { + preferred = 'Only Torrent'; + } else if (!enableTorrent) { + preferred = 'Only Usenet'; + } + + return ( +
+
{preferred}
+
{getDelay(enableUsenet, usenetDelay)}
+
{getDelay(enableTorrent, torrentDelay)}
+ + + +
+ + + + + { + id !== 1 && + connectDragSource( +
+ +
+ ) + } +
+ + + + +
+ ); + } +} + +DelayProfile.propTypes = { + id: PropTypes.number.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + preferredProtocol: PropTypes.string.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + isDragging: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onConfirmDeleteDelayProfile: PropTypes.func.isRequired +}; + +DelayProfile.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default DelayProfile; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css new file mode 100644 index 000000000..cc5a92830 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css @@ -0,0 +1,3 @@ +.dragPreview { + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js new file mode 100644 index 000000000..402ddcc13 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { DELAY_PROFILE } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import DelayProfile from './DelayProfile'; +import styles from './DelayProfileDragPreview.css'; + +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class DelayProfileDragPreview extends Component { + + // + // Render + + render() { + const { + width, + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== DELAY_PROFILE) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = width - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + width, + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + return ( + +
+ +
+
+ ); + } +} + +DelayProfileDragPreview.propTypes = { + width: PropTypes.number.isRequired, + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(DelayProfileDragPreview); diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css new file mode 100644 index 000000000..835250678 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css @@ -0,0 +1,17 @@ +.delayProfileDragSource { + padding: 4px 0; +} + +.delayProfilePlaceholder { + width: 100%; + height: 30px; + border-bottom: 1px dotted #aaa; +} + +.delayProfilePlaceholderBefore { + margin-bottom: 8px; +} + +.delayProfilePlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js new file mode 100644 index 000000000..5c1c565e0 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js @@ -0,0 +1,148 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { DELAY_PROFILE } from 'Helpers/dragTypes'; +import DelayProfile from './DelayProfile'; +import styles from './DelayProfileDragSource.css'; + +const delayProfileDragSource = { + beginDrag(item) { + return item; + }, + + endDrag(props, monitor, component) { + props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const delayProfileDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().order; + const hoverIndex = props.order; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex === hoverIndex) { + return; + } + + // When moving up, only trigger if drag position is above 50% and + // when moving down, only trigger if drag position is below 50%. + // If we're moving down the hoverIndex needs to be increased + // by one so it's ordered properly. Otherwise the hoverIndex will work. + + if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) { + props.onDelayProfileDragMove(dragIndex, hoverIndex + 1); + } else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) { + props.onDelayProfileDragMove(dragIndex, hoverIndex); + } + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class DelayProfileDragSource extends Component { + + // + // Render + + render() { + const { + id, + order, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + ...otherProps + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
+ { + isBefore && +
+ } + + + + { + isAfter && +
+ } +
+ ); + } +} + +DelayProfileDragSource.propTypes = { + id: PropTypes.number.isRequired, + order: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onDelayProfileDragMove: PropTypes.func.isRequired, + onDelayProfileDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + DELAY_PROFILE, + delayProfileDropTarget, + collectDropTarget +)(DragSource( + DELAY_PROFILE, + delayProfileDragSource, + collectDragSource +)(DelayProfileDragSource)); diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css new file mode 100644 index 000000000..3cf3e9020 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css @@ -0,0 +1,27 @@ +.delayProfiles { + user-select: none; +} + +.delayProfilesHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.column { + flex: 0 0 200px; +} + +.tags { + flex: 1 0 auto; +} + +.addDelayProfile { + display: flex; + justify-content: flex-end; +} + +.addButton { + width: $dragHandleWidth; + text-align: center; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js new file mode 100644 index 000000000..a745da9d4 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js @@ -0,0 +1,148 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import Measure from 'Components/Measure'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import DelayProfileDragSource from './DelayProfileDragSource'; +import DelayProfileDragPreview from './DelayProfileDragPreview'; +import DelayProfile from './DelayProfile'; +import EditDelayProfileModalConnector from './EditDelayProfileModalConnector'; +import styles from './DelayProfiles.css'; + +class DelayProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddDelayProfileModalOpen: false, + width: 0 + }; + } + + // + // Listeners + + onAddDelayProfilePress = () => { + this.setState({ isAddDelayProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddDelayProfileModalOpen: false }); + } + + onMeasure = ({ width }) => { + this.setState({ width }); + } + + // + // Render + + render() { + const { + defaultProfile, + items, + tagList, + dragIndex, + dropIndex, + onConfirmDeleteDelayProfile, + ...otherProps + } = this.props; + + const { + isAddDelayProfileModalOpen, + width + } = this.state; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex < dragIndex; + const isDraggingDown = isDragging && dropIndex > dragIndex; + + return ( + +
+ +
+
Protocol
+
Usenet Delay
+
Torrent Delay
+
Tags
+
+ +
+ { + items.map((item, index) => { + return ( + + ); + }) + } + + +
+ + { + defaultProfile && +
+ +
+ } + +
+ + + +
+ + +
+
+
+ ); + } +} + +DelayProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + defaultProfile: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + dragIndex: PropTypes.number, + dropIndex: PropTypes.number, + onConfirmDeleteDelayProfile: PropTypes.func.isRequired +}; + +export default DelayProfiles; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js new file mode 100644 index 000000000..16fe5718c --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js @@ -0,0 +1,105 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDelayProfiles, deleteDelayProfile, reorderDelayProfile } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import DelayProfiles from './DelayProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.delayProfiles, + createTagsSelector(), + (delayProfiles, tagList) => { + const defaultProfile = _.find(delayProfiles.items, { id: 1 }); + const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']); + + return { + defaultProfile, + ...delayProfiles, + items, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchDelayProfiles, + deleteDelayProfile, + reorderDelayProfile +}; + +class DelayProfilesConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragIndex: null, + dropIndex: null + }; + } + + componentDidMount() { + this.props.fetchDelayProfiles(); + } + + // + // Listeners + + onConfirmDeleteDelayProfile = (id) => { + this.props.deleteDelayProfile({ id }); + } + + onDelayProfileDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onDelayProfileDragEnd = ({ id }, didDrop) => { + const { + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DelayProfilesConnector.propTypes = { + fetchDelayProfiles: PropTypes.func.isRequired, + deleteDelayProfile: PropTypes.func.isRequired, + reorderDelayProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js new file mode 100644 index 000000000..9444fd65e --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector'; + +function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditDelayProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditDelayProfileModal; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js new file mode 100644 index 000000000..a1e8d2dcd --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditDelayProfileModal from './EditDelayProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditDelayProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.delayProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDelayProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js new file mode 100644 index 000000000..8960cd2b7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Alert from 'Components/Alert'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditDelayProfileModalContent.css'; + +function EditDelayProfileModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + protocol, + protocolOptions, + onInputChange, + onProtocolChange, + onSavePress, + onModalClose, + onDeleteDelayProfilePress, + ...otherProps + } = props; + + const { + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay, + tags + } = item; + + return ( + + + {id ? 'Edit Delay Profile' : 'Add Delay Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new quality profile, please try again.
+ } + + { + !isFetching && !error && +
+ + Protocol + + + + + { + enableUsenet.value && + + Usenet Delay + + + + } + + { + enableTorrent.value && + + Torrent Delay + + + + } + + { + id === 1 ? + + This is the default profile. It applies to all series that don't have an explicit profile. + : + + + Tags + + + + } +
+ } +
+ + { + id && id > 1 && + + } + + + + + Save + + +
+ ); +} + +const delayProfileShape = { + enableUsenet: PropTypes.shape(boolSettingShape).isRequired, + enableTorrent: PropTypes.shape(boolSettingShape).isRequired, + usenetDelay: PropTypes.shape(numberSettingShape).isRequired, + torrentDelay: PropTypes.shape(numberSettingShape).isRequired, + order: PropTypes.shape(numberSettingShape), + tags: PropTypes.shape(tagSettingShape).isRequired +}; + +EditDelayProfileModalContent.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.shape(delayProfileShape).isRequired, + protocol: PropTypes.string.isRequired, + protocolOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + onInputChange: PropTypes.func.isRequired, + onProtocolChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteDelayProfilePress: PropTypes.func +}; + +export default EditDelayProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js new file mode 100644 index 000000000..5b7e036f5 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js @@ -0,0 +1,178 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setDelayProfileValue, saveDelayProfile } from 'Store/Actions/settingsActions'; +import EditDelayProfileModalContent from './EditDelayProfileModalContent'; + +const newDelayProfile = { + enableUsenet: true, + enableTorrent: true, + preferredProtocol: 'usenet', + usenetDelay: 0, + torrentDelay: 0, + tags: [] +}; + +const protocolOptions = [ + { key: 'preferUsenet', value: 'Prefer Usenet' }, + { key: 'preferTorrent', value: 'Prefer Torrent' }, + { key: 'onlyUsenet', value: 'Only Usenet' }, + { key: 'onlyTorrent', value: 'Only Torrent' } +]; + +function createDelayProfileSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.delayProfiles, + (id, delayProfiles) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = delayProfiles; + + const profile = id ? _.find(items, { id }) : newDelayProfile; + const settings = selectSettings(profile, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createDelayProfileSelector(), + (delayProfile) => { + const enableUsenet = delayProfile.item.enableUsenet.value; + const enableTorrent = delayProfile.item.enableTorrent.value; + const preferredProtocol = delayProfile.item.preferredProtocol.value; + let protocol = 'preferUsenet'; + + if (preferredProtocol === 'usenet') { + protocol = 'preferUsenet'; + } else { + protocol = 'preferTorrent'; + } + + if (!enableUsenet) { + protocol = 'onlyTorrent'; + } + + if (!enableTorrent) { + protocol = 'onlyUsenet'; + } + + return { + protocol, + protocolOptions, + ...delayProfile + }; + } + ); +} + +const mapDispatchToProps = { + setDelayProfileValue, + saveDelayProfile +}; + +class EditDelayProfileModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newDelayProfile).forEach((name) => { + this.props.setDelayProfileValue({ + name, + value: newDelayProfile[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setDelayProfileValue({ name, value }); + } + + onProtocolChange = ({ value }) => { + switch (value) { + case 'preferUsenet': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' }); + break; + case 'preferTorrent': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' }); + break; + case 'onlyUsenet': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: false }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' }); + break; + case 'onlyTorrent': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: false }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' }); + break; + default: + throw Error(`Unknown protocol option: ${value}`); + } + } + + onSavePress = () => { + this.props.saveDelayProfile({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDelayProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setDelayProfileValue: PropTypes.func.isRequired, + saveDelayProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js new file mode 100644 index 000000000..0479ccd69 --- /dev/null +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityProfilesConnector from './Quality/QualityProfilesConnector'; +import DelayProfilesConnector from './Delay/DelayProfilesConnector'; + +class Profiles extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + ); + } +} + +// Only a single DragDropContext can exist so it's done here to allow editing +// quality profiles and reordering delay profiles to work. + +export default DragDropContext(HTML5Backend)(Profiles); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js new file mode 100644 index 000000000..9ecbd1ca8 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector'; + +class EditQualityProfileModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 'auto' + }; + } + + // + // Listeners + + onContentHeightChange = (height) => { + if (this.state.height === 'auto' || height > this.state.height) { + this.setState({ height }); + } + } + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EditQualityProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditQualityProfileModal; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js new file mode 100644 index 000000000..942949cac --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditQualityProfileModal from './EditQualityProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditQualityProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.qualityProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditQualityProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css new file mode 100644 index 000000000..2f6589933 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css @@ -0,0 +1,18 @@ +.formGroupsContainer { + display: flex; + flex-wrap: wrap; +} + +.formGroupWrapper { + flex: 0 0 calc($formGroupSmallWidth - 100px); +} + +.deleteButtonContainer { + margin-right: auto; +} + +@media only screen and (max-width: $breakpointLarge) { + .formGroupsContainer { + display: block; + } +} diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js new file mode 100644 index 000000000..e390f251d --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -0,0 +1,252 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Measure from 'Components/Measure'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import QualityProfileItems from './QualityProfileItems'; +import styles from './EditQualityProfileModalContent.css'; + +const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding); + +class EditQualityProfileModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + headerHeight: 0, + bodyHeight: 0, + footerHeight: 0 + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + headerHeight, + bodyHeight, + footerHeight + } = this.state; + + if ( + headerHeight > 0 && + bodyHeight > 0 && + footerHeight > 0 && + ( + headerHeight !== prevState.headerHeight || + bodyHeight !== prevState.bodyHeight || + footerHeight !== prevState.footerHeight + ) + ) { + const padding = MODAL_BODY_PADDING * 2; + + this.props.onContentHeightChange( + headerHeight + bodyHeight + footerHeight + padding + ); + } + } + + // + // Listeners + + onHeaderMeasure = ({ height }) => { + if (height > this.state.headerHeight) { + this.setState({ headerHeight: height }); + } + } + + onBodyMeasure = ({ height }) => { + + if (height > this.state.bodyHeight) { + this.setState({ bodyHeight: height }); + } + } + + onFooterMeasure = ({ height }) => { + if (height > this.state.footerHeight) { + this.setState({ footerHeight: height }); + } + } + + // + // Render + + render() { + const { + editGroups, + isFetching, + error, + isSaving, + saveError, + qualities, + item, + isInUse, + onInputChange, + onCutoffChange, + onSavePress, + onModalClose, + onDeleteQualityProfilePress, + ...otherProps + } = this.props; + + const { + id, + name, + cutoff, + items + } = item; + + return ( + + + + {id ? 'Edit Quality Profile' : 'Add Quality Profile'} + + + + + +
+ { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new quality profile, please try again.
+ } + + { + !isFetching && !error && +
+
+
+ + + Name + + + + + + + + Cutoff + + + + +
+ +
+ +
+
+
+ + } +
+
+
+ + + + { + id && +
+ +
+ } + + + + + Save + +
+
+
+ ); + } +} + +EditQualityProfileModalContent.propTypes = { + editGroups: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onCutoffChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onContentHeightChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteQualityProfilePress: PropTypes.func +}; + +export default EditQualityProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js new file mode 100644 index 000000000..ea93ed2dd --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js @@ -0,0 +1,442 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions'; +import EditQualityProfileModalContent from './EditQualityProfileModalContent'; + +function getQualityItemGroupId(qualityProfile) { + // Get items with an `id` and filter out null/undefined values + const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null); + + return Math.max(1000, ...ids) + 1; +} + +function parseIndex(index) { + const split = index.split('.'); + + if (split.length === 1) { + return [ + null, + parseInt(split[0]) - 1 + ]; + } + + return [ + parseInt(split[0]) - 1, + parseInt(split[1]) - 1 + ]; +} + +function createQualitiesSelector() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + (qualityProfile) => { + const items = qualityProfile.item.items; + if (!items || !items.value) { + return []; + } + + return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => { + if (allowed) { + if (id) { + result.push({ + key: id, + value: name + }); + } else { + result.push({ + key: quality.id, + value: quality.name + }); + } + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + createQualitiesSelector(), + createProfileInUseSelector('qualityProfileId'), + (qualityProfile, qualities, isInUse) => { + return { + qualities, + ...qualityProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchQualityProfileSchema, + setQualityProfileValue, + saveQualityProfile +}; + +class EditQualityProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null, + editGroups: false + }; + } + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchQualityProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Control + + ensureCutoff = (qualityProfile) => { + const cutoff = qualityProfile.cutoff.value; + + const cutoffItem = _.find(qualityProfile.items.value, (i) => { + if (!cutoff) { + return false; + } + + return i.id === cutoff.id || (i.quality && i.quality.id === cutoff.id); + }); + + // If the cutoff isn't allowed anymore or there isn't a cutoff set one + if (!cutoff || !cutoffItem || !cutoffItem.allowed) { + const firstAllowed = _.find(qualityProfile.items.value, { allowed: true }); + let cutoffId = null; + + if (firstAllowed) { + cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id; + } + + this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setQualityProfileValue({ name, value }); + } + + onCutoffChange = ({ name, value }) => { + const id = parseInt(value); + const item = _.find(this.props.item.items.value, (i) => { + if (i.quality) { + return i.quality.id === id; + } + + return i.id === id; + }); + + const cutoffId = item.quality ? item.quality.id : item.id; + + this.props.setQualityProfileValue({ name, value: cutoffId }); + } + + onSavePress = () => { + this.props.saveQualityProfile({ id: this.props.id }); + } + + onQualityProfileItemAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id); + + item.allowed = allowed; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.id === id); + + item.allowed = allowed; + + // Update each item in the group (for consistency only) + item.items.forEach((i) => { + i.allowed = allowed; + }); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupNameChange = (id, name) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + + group.name = name; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + } + + onCreateGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(items, (i) => i.quality && i.quality.id === id); + const index = items.indexOf(item); + const groupId = getQualityItemGroupId(qualityProfile); + + const group = { + id: groupId, + name: item.quality.name, + allowed: item.allowed, + items: [ + item + ] + }; + + // Add the group in the same location the quality item was in. + items.splice(index, 1, group); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onDeleteGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + const index = items.indexOf(group); + + // Add the items in the same location the group was in + items.splice(index, 1, ...group.items); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onQualityProfileItemDragMove = (options) => { + const { + dragQualityIndex, + dropQualityIndex, + dropPosition + } = options; + + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + if ( + (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) || + (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex) + ) { + if ( + this.state.dragQualityIndex != null && + this.state.dropQualityIndex != null && + this.state.dropPosition != null + ) { + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + return; + } + + let adjustedDropQualityIndex = dropQualityIndex; + + // Correct dragging out of a group to the position above + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex != null + ) { + // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`; + } + + // Correct inserting above outside a group + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex == null + ) { + // Add 2 to the item index so it's entered in the correct place + adjustedDropQualityIndex = `${dropItemIndex + 2}`; + } + + // Correct inserting below a quality within the same group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex != null && + dragItemIndex < dropItemIndex + ) { + // Add 1 to the group index leave the item index + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`; + } + + // Correct inserting below a quality outside a group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex == null && + dragItemIndex < dropItemIndex + ) { + // Leave the item index so it's inserted below the item + adjustedDropQualityIndex = `${dropItemIndex}`; + } + + if ( + dragQualityIndex !== this.state.dragQualityIndex || + adjustedDropQualityIndex !== this.state.dropQualityIndex || + dropPosition !== this.state.dropPosition + ) { + this.setState({ + dragQualityIndex, + dropQualityIndex: adjustedDropQualityIndex, + dropPosition + }); + } + } + + onQualityProfileItemDragEnd = (didDrop) => { + const { + dragQualityIndex, + dropQualityIndex + } = this.state; + + if (didDrop && dropQualityIndex != null) { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + let item = null; + let dropGroup = null; + + // Get the group before moving anything so we know the correct place to drop it. + if (dropGroupIndex != null) { + dropGroup = items[dropGroupIndex]; + } + + if (dragGroupIndex == null) { + item = items.splice(dragItemIndex, 1)[0]; + } else { + const group = items[dragGroupIndex]; + item = group.items.splice(dragItemIndex, 1)[0]; + + // If the group is now empty, destroy it. + if (!group.items.length) { + items.splice(dragGroupIndex, 1); + } + } + + if (dropGroupIndex == null) { + items.splice(dropItemIndex, 0, item); + } else { + dropGroup.items.splice(dropItemIndex, 0, item); + } + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + onToggleEditGroupsMode = () => { + this.setState({ editGroups: !this.state.editGroups }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.items) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditQualityProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setQualityProfileValue: PropTypes.func.isRequired, + fetchQualityProfileSchema: PropTypes.func.isRequired, + saveQualityProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css new file mode 100644 index 000000000..341d75aa2 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css @@ -0,0 +1,38 @@ +.qualityProfile { + composes: card from 'Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from 'Components/Link/IconButton.css'; + + height: 36px; +} + +.qualities { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from 'Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js new file mode 100644 index 000000000..65da9fa3b --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js @@ -0,0 +1,184 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; +import styles from './QualityProfile.css'; + +class QualityProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: false + }; + } + + // + // Listeners + + onEditQualityProfilePress = () => { + this.setState({ isEditQualityProfileModalOpen: true }); + } + + onEditQualityProfileModalClose = () => { + this.setState({ isEditQualityProfileModalOpen: false }); + } + + onDeleteQualityProfilePress = () => { + this.setState({ + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: true + }); + } + + onDeleteQualityProfileModalClose = () => { + this.setState({ isDeleteQualityProfileModalOpen: false }); + } + + onConfirmDeleteQualityProfile = () => { + this.props.onConfirmDeleteQualityProfile(this.props.id); + } + + onCloneQualityProfilePress = () => { + const { + id, + onCloneQualityProfilePress + } = this.props; + + onCloneQualityProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + cutoff, + items, + isDeleting + } = this.props; + + return ( + +
+
+ {name} +
+ + +
+ +
+ { + items.map((item) => { + if (!item.allowed) { + return null; + } + + if (item.quality) { + const isCutoff = item.quality.id === cutoff.id; + + return ( + + ); + } + + const isCutoff = item.id === cutoff.id; + + return ( + + {item.name} + + } + tooltip={ +
+ { + item.items.map((groupItem) => { + return ( + + ); + }) + } +
+ } + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> + ); + }) + } +
+ + + + +
+ ); + } +} + +QualityProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + cutoff: PropTypes.object.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteQualityProfile: PropTypes.func.isRequired, + onCloneQualityProfilePress: PropTypes.func.isRequired +}; + +export default QualityProfile; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css new file mode 100644 index 000000000..7e6370ff8 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css @@ -0,0 +1,85 @@ +.qualityProfileItem { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; + + &.isInGroup { + border-style: dashed; + } +} + +.checkInputContainer { + position: relative; + margin-right: 4px; + margin-bottom: 5px; + margin-left: 8px; +} + +.checkInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 5px; +} + +.qualityNameContainer { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: $qualityProfileItemHeight; + cursor: pointer; +} + +.qualityName { + &.isInGroup { + margin-left: 14px; + } + + &.notAllowed { + color: #c6c6c6; + } +} + +.createGroupButton { + composes: buton from 'Components/Link/IconButton.css'; + + display: flex; + justify-content: center; + flex-shrink: 0; + margin-right: 5px; + margin-left: 8px; + width: 20px; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.isPreview { + .qualityName { + margin-left: 14px; + + &.isInGroup { + margin-left: 28px; + } + } +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js new file mode 100644 index 000000000..8161e7061 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './QualityProfileItem.css'; + +class QualityProfileItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + qualityId, + onQualityProfileItemAllowedChange + } = this.props; + + onQualityProfileItemAllowedChange(qualityId, value); + } + + onCreateGroupPress = () => { + const { + qualityId, + onCreateGroupPress + } = this.props; + + onCreateGroupPress(qualityId); + } + + // + // Render + + render() { + const { + editGroups, + isPreview, + groupId, + name, + allowed, + isDragging, + isOverCurrent, + connectDragSource + } = this.props; + + return ( +
+ + + { + connectDragSource( +
+ +
+ ) + } +
+ ); + } +} + +QualityProfileItem.propTypes = { + editGroups: PropTypes.bool, + isPreview: PropTypes.bool, + groupId: PropTypes.number, + qualityId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + isDragging: PropTypes.bool.isRequired, + isOverCurrent: PropTypes.bool.isRequired, + isInGroup: PropTypes.bool, + connectDragSource: PropTypes.func, + onCreateGroupPress: PropTypes.func, + onQualityProfileItemAllowedChange: PropTypes.func +}; + +QualityProfileItem.defaultProps = { + isPreview: false, + isOverCurrent: false, + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default QualityProfileItem; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js new file mode 100644 index 000000000..e0c6e8e8c --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import QualityProfileItem from './QualityProfileItem'; +import styles from './QualityProfileItemDragPreview.css'; + +const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth); +const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class QualityProfileItemDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + const { + editGroups, + groupId, + qualityId, + name, + allowed + } = item; + + // TODO: Show a different preview for groups + + return ( + +
+ +
+
+ ); + } +} + +QualityProfileItemDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css new file mode 100644 index 000000000..d5061cc95 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css @@ -0,0 +1,18 @@ +.qualityProfileItemDragSource { + padding: $qualityProfileItemDragSourcePadding 0; +} + +.qualityProfileItemPlaceholder { + width: 100%; + height: $qualityProfileItemHeight; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.qualityProfileItemPlaceholderBefore { + margin-bottom: 8px; +} + +.qualityProfileItemPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js new file mode 100644 index 000000000..0e1838eb3 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js @@ -0,0 +1,241 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import QualityProfileItem from './QualityProfileItem'; +import QualityProfileItemGroup from './QualityProfileItemGroup'; +import styles from './QualityProfileItemDragSource.css'; + +const qualityProfileItemDragSource = { + beginDrag(props) { + const { + editGroups, + qualityIndex, + groupId, + qualityId, + name, + allowed + } = props; + + return { + editGroups, + qualityIndex, + groupId, + qualityId, + isGroup: !qualityId, + name, + allowed + }; + }, + + endDrag(props, monitor, component) { + props.onQualityProfileItemDragEnd(monitor.didDrop()); + } +}; + +const qualityProfileItemDropTarget = { + hover(props, monitor, component) { + const { + qualityIndex: dragQualityIndex, + isGroup: isDragGroup + } = monitor.getItem(); + + const dropQualityIndex = props.qualityIndex; + const isDropGroupItem = !!(props.qualityId && props.groupId); + + // Use childNodeIndex to select the correct node to get the middle of so + // we don't bounce between above and below causing rapid setState calls. + const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0; + const componentDOMNode = findDOMNode(component).children[childNodeIndex]; + const hoverBoundingRect = componentDOMNode.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // If we're hovering over a child don't trigger on the parent + if (!monitor.isOver({ shallow: true })) { + return; + } + + // Don't show targets for dropping on self + if (dragQualityIndex === dropQualityIndex) { + return; + } + + // Don't allow a group to be dropped inside a group + if (isDragGroup && isDropGroupItem) { + return; + } + + let dropPosition = null; + + // Determine drop position based on position over target + if (hoverClientY > hoverMiddleY) { + dropPosition = 'below'; + } else if (hoverClientY < hoverMiddleY) { + dropPosition = 'above'; + } else { + return; + } + + props.onQualityProfileItemDragMove({ + dragQualityIndex, + dropQualityIndex, + dropPosition + }); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + isOverCurrent: monitor.isOver({ shallow: true }) + }; +} + +class QualityProfileItemDragSource extends Component { + + // + // Render + + render() { + const { + editGroups, + groupId, + qualityId, + name, + allowed, + items, + qualityIndex, + isDragging, + isDraggingUp, + isDraggingDown, + isOverCurrent, + connectDragSource, + connectDropTarget, + onCreateGroupPress, + onDeleteGroupPress, + onQualityProfileItemAllowedChange, + onItemGroupAllowedChange, + onItemGroupNameChange, + onQualityProfileItemDragMove, + onQualityProfileItemDragEnd + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOverCurrent; + const isAfter = !isDragging && isDraggingDown && isOverCurrent; + + return connectDropTarget( +
+ { + isBefore && +
+ } + + { + !!groupId && qualityId == null && + + } + + { + qualityId != null && + + } + + { + isAfter && +
+ } +
+ ); + } +} + +QualityProfileItemDragSource.propTypes = { + editGroups: PropTypes.bool.isRequired, + groupId: PropTypes.number, + qualityId: PropTypes.number, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + qualityIndex: PropTypes.string.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOverCurrent: PropTypes.bool, + isInGroup: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onCreateGroupPress: PropTypes.func, + onDeleteGroupPress: PropTypes.func, + onQualityProfileItemAllowedChange: PropTypes.func.isRequired, + onItemGroupAllowedChange: PropTypes.func, + onItemGroupNameChange: PropTypes.func, + onQualityProfileItemDragMove: PropTypes.func.isRequired, + onQualityProfileItemDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + QUALITY_PROFILE_ITEM, + qualityProfileItemDropTarget, + collectDropTarget +)(DragSource( + QUALITY_PROFILE_ITEM, + qualityProfileItemDragSource, + collectDragSource +)(QualityProfileItemDragSource)); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css new file mode 100644 index 000000000..d0720cb6a --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css @@ -0,0 +1,105 @@ +.qualityProfileItemGroup { + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; + + &.editGroups { + background: #fcfcfc; + } +} + +.qualityProfileItemGroupInfo { + display: flex; + align-items: stretch; + width: 100%; +} + +.checkInputContainer { + composes: checkInputContainer from './QualityProfileItem.css'; + + display: flex; + align-items: center; +} + +.checkInput { + composes: checkInput from './QualityProfileItem.css'; +} + +.nameInput { + composes: input from 'Components/Form/TextInput.css'; + + margin-top: 4px; + margin-right: 10px; +} + +.nameContainer { + display: flex; + align-items: center; + flex-grow: 1; +} + +.name { + flex-shrink: 0; + + &.notAllowed { + color: #c6c6c6; + } +} + +.groupQualities { + display: flex; + justify-content: flex-end; + flex-grow: 1; + flex-wrap: wrap; + margin: 2px 0 2px 10px; +} + +.qualityNameContainer { + display: flex; + align-items: stretch; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; +} + +.qualityNameLabel { + composes: qualityNameContainer; + + cursor: pointer; +} + +.deleteGroupButton { + composes: buton from 'Components/Link/IconButton.css'; + + display: flex; + justify-content: center; + flex-shrink: 0; + margin-right: 5px; + margin-left: 8px; + width: 20px; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.items { + margin: 0 50px 0 35px; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js new file mode 100644 index 000000000..34008b1ec --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import CheckInput from 'Components/Form/CheckInput'; +import TextInput from 'Components/Form/TextInput'; +import QualityProfileItemDragSource from './QualityProfileItemDragSource'; +import styles from './QualityProfileItemGroup.css'; + +class QualityProfileItemGroup extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + groupId, + onItemGroupAllowedChange + } = this.props; + + onItemGroupAllowedChange(groupId, value); + } + + onNameChange = ({ value }) => { + const { + groupId, + onItemGroupNameChange + } = this.props; + + onItemGroupNameChange(groupId, value); + } + + onDeleteGroupPress = ({ value }) => { + const { + groupId, + onDeleteGroupPress + } = this.props; + + onDeleteGroupPress(groupId, value); + } + + // + // Render + + render() { + const { + editGroups, + groupId, + name, + allowed, + items, + qualityIndex, + isDragging, + isDraggingUp, + isDraggingDown, + connectDragSource, + onQualityProfileItemAllowedChange, + onQualityProfileItemDragMove, + onQualityProfileItemDragEnd + } = this.props; + + return ( +
+
+ { + editGroups && +
+ + + +
+ } + + { + !editGroups && + + } + + { + connectDragSource( +
+ +
+ ) + } +
+ + { + editGroups && +
+ { + items.map(({ quality }, index) => { + return ( + + ); + }).reverse() + } +
+ } +
+ ); + } +} + +QualityProfileItemGroup.propTypes = { + editGroups: PropTypes.bool, + groupId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + qualityIndex: PropTypes.string.isRequired, + isDragging: PropTypes.bool.isRequired, + isDraggingUp: PropTypes.bool.isRequired, + isDraggingDown: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onItemGroupAllowedChange: PropTypes.func.isRequired, + onQualityProfileItemAllowedChange: PropTypes.func.isRequired, + onItemGroupNameChange: PropTypes.func.isRequired, + onDeleteGroupPress: PropTypes.func.isRequired, + onQualityProfileItemDragMove: PropTypes.func.isRequired, + onQualityProfileItemDragEnd: PropTypes.func.isRequired +}; + +QualityProfileItemGroup.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default QualityProfileItemGroup; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css new file mode 100644 index 000000000..6b4268de9 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css @@ -0,0 +1,15 @@ +.editGroupsButton { + composes: button from 'Components/Link/Button.css'; + + margin-top: 10px; +} + +.editGroupsButtonIcon { + margin-right: 8px; +} + +.qualities { + margin-top: 10px; + transition: min-height 200ms; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js new file mode 100644 index 000000000..c41d4b77d --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js @@ -0,0 +1,181 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import Measure from 'Components/Measure'; +import QualityProfileItemDragSource from './QualityProfileItemDragSource'; +import QualityProfileItemDragPreview from './QualityProfileItemDragPreview'; +import styles from './QualityProfileItems.css'; + +class QualityProfileItems extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + qualitiesHeight: 0, + qualitiesHeightEditGroups: 0 + }; + } + + // + // Listeners + + onMeasure = ({ height }) => { + if (this.props.editGroups) { + this.setState({ + qualitiesHeightEditGroups: height + }); + } else { + this.setState({ qualitiesHeight: height }); + } + } + + onToggleEditGroupsMode = () => { + this.props.onToggleEditGroupsMode(); + } + + // + // Render + + render() { + const { + editGroups, + dropQualityIndex, + dropPosition, + qualityProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + const { + qualitiesHeight, + qualitiesHeightEditGroups + } = this.state; + + const isDragging = dropQualityIndex !== null; + const isDraggingUp = isDragging && dropPosition === 'above'; + const isDraggingDown = isDragging && dropPosition === 'below'; + const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight; + + return ( + + + Qualities + + +
+ + + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + + + + +
+ { + qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => { + const identifier = quality ? quality.id : id; + + return ( + + ); + }).reverse() + } + + +
+
+
+
+ ); + } +} + +QualityProfileItems.propTypes = { + editGroups: PropTypes.bool.isRequired, + dragQualityIndex: PropTypes.string, + dropQualityIndex: PropTypes.string, + dropPosition: PropTypes.string, + qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + onToggleEditGroupsMode: PropTypes.func.isRequired +}; + +QualityProfileItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default QualityProfileItems; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js new file mode 100644 index 000000000..bf13815ff --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; + +function createMapStateToProps() { + return createSelector( + createQualityProfileSelector(), + (qualityProfile) => { + return { + name: qualityProfile.name + }; + } + ); +} + +function QualityProfileNameConnector({ name, ...otherProps }) { + return ( + + {name} + + ); +} + +QualityProfileNameConnector.propTypes = { + qualityProfileId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(QualityProfileNameConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css new file mode 100644 index 000000000..9644a7c2d --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css @@ -0,0 +1,21 @@ +.qualityProfiles { + display: flex; + flex-wrap: wrap; +} + +.addQualityProfile { + composes: qualityProfile from './QualityProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js new file mode 100644 index 000000000..cf1a21422 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import QualityProfile from './QualityProfile'; +import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; +import styles from './QualityProfiles.css'; + +class QualityProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isQualityProfileModalOpen: false + }; + } + + // + // Listeners + + onCloneQualityProfilePress = (id) => { + this.props.onCloneQualityProfilePress(id); + this.setState({ isQualityProfileModalOpen: true }); + } + + onEditQualityProfilePress = () => { + this.setState({ isQualityProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isQualityProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteQualityProfile, + onCloneQualityProfilePress, + ...otherProps + } = this.props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + +
+
+ ); + } +} + +QualityProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteQualityProfile: PropTypes.func.isRequired, + onCloneQualityProfilePress: PropTypes.func.isRequired +}; + +export default QualityProfiles; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js new file mode 100644 index 000000000..c7596ad63 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions'; +import QualityProfiles from './QualityProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + return { + ...qualityProfiles + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfiles: fetchQualityProfiles, + dispatchDeleteQualityProfile: deleteQualityProfile, + dispatchCloneQualityProfile: cloneQualityProfile +}; + +class QualityProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityProfiles(); + } + + // + // Listeners + + onConfirmDeleteQualityProfile = (id) => { + this.props.dispatchDeleteQualityProfile({ id }); + } + + onCloneQualityProfilePress = (id) => { + this.props.dispatchCloneQualityProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityProfilesConnector.propTypes = { + dispatchFetchQualityProfiles: PropTypes.func.isRequired, + dispatchDeleteQualityProfile: PropTypes.func.isRequired, + dispatchCloneQualityProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css new file mode 100644 index 000000000..ccfd00c7a --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css @@ -0,0 +1,93 @@ +.qualityDefinition { + display: flex; + align-content: stretch; + margin: 5px 0; + padding-top: 5px; + height: 45px; + border-top: 1px solid $borderColor; +} + +.quality, +.title { + flex: 0 1 250px; + padding-right: 20px; + line-height: 40px; +} + +.sizeLimit { + flex: 0 1 500px; + padding-right: 30px; +} + +.slider { + width: 100%; + height: 20px; +} + +.bar { + top: 9px; + margin: 0 5px; + height: 3px; + background-color: $sliderAccentColor; + box-shadow: 0 0 0 #000; + + &:nth-child(odd) { + background-color: #ddd; + } +} + +.handle { + top: 1px; + z-index: 0 !important; + width: 18px; + height: 18px; + border: 3px solid $sliderAccentColor; + border-radius: 50%; + background-color: $white; + text-align: center; + cursor: pointer; +} + +.sizes { + display: flex; + justify-content: space-between; +} + +.megabytesPerMinute { + display: flex; + justify-content: space-between; + flex: 0 0 250px; +} + +.sizeInput { + composes: input from 'Components/Form/TextInput.css'; + + display: inline-block; + margin-left: 5px; + padding: 6px; + width: 75px; +} + +@media only screen and (max-width: $breakpointSmall) { + .qualityDefinition { + flex-wrap: wrap; + height: auto; + + &:first-child { + border-top: none; + } + } + + .qualityDefinition:first-child { + border-top: none; + } + + .quality { + font-weight: bold; + line-height: inherit; + } + + .sizeLimit { + margin-top: 10px; + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js new file mode 100644 index 000000000..41af7cf1c --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -0,0 +1,242 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactSlider from 'react-slider'; +import formatBytes from 'Utilities/Number/formatBytes'; +import roundNumber from 'Utilities/Number/roundNumber'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import NumberInput from 'Components/Form/NumberInput'; +import TextInput from 'Components/Form/TextInput'; +import styles from './QualityDefinition.css'; + +const MIN = 0; +const MAX = 400; + +const slider = { + min: MIN, + max: roundNumber(Math.pow(MAX, 1 / 1.1)), + step: 0.1 +}; + +function getValue(inputValue) { + if (inputValue < MIN) { + return MIN; + } + + if (inputValue > MAX) { + return MAX; + } + + return roundNumber(inputValue); +} + +function getSliderValue(value, defaultValue) { + const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue; + + return roundNumber(sliderValue); +} + +class QualityDefinition extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._forceUpdateTimeout = null; + + this.state = { + sliderMinSize: getSliderValue(props.minSize, slider.min), + sliderMaxSize: getSliderValue(props.maxSize, slider.max) + }; + } + + componentDidMount() { + // A hack to deal with a bug in the slider component until a fix for it + // lands and an updated version is available. + // See: https://github.com/mpowaga/react-slider/issues/115 + + this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1); + } + + componentWillUnmount() { + if (this._forceUpdateTimeout) { + clearTimeout(this._forceUpdateTimeout); + } + } + + // + // Listeners + + onSliderChange = ([sliderMinSize, sliderMaxSize]) => { + this.setState({ + sliderMinSize, + sliderMaxSize + }); + + this.props.onSizeChange({ + minSize: roundNumber(Math.pow(sliderMinSize, 1.1)), + maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1)) + }); + } + + onAfterSliderChange = () => { + const { + minSize, + maxSize + } = this.props; + + this.setState({ + sliderMiSize: getSliderValue(minSize, slider.min), + sliderMaxSize: getSliderValue(maxSize, slider.max) + }); + } + + onMinSizeChange = ({ value }) => { + const minSize = getValue(value); + + this.setState({ + sliderMinSize: getSliderValue(minSize, slider.min) + }); + + this.props.onSizeChange({ + minSize, + maxSize: this.props.maxSize + }); + } + + onMaxSizeChange = ({ value }) => { + const maxSize = value === MAX ? null : getValue(value); + + this.setState({ + sliderMaxSize: getSliderValue(maxSize, slider.max) + }); + + this.props.onSizeChange({ + minSize: this.props.minSize, + maxSize + }); + } + + // + // Render + + render() { + const { + id, + quality, + title, + minSize, + maxSize, + advancedSettings, + onTitleChange + } = this.props; + + const { + sliderMinSize, + sliderMaxSize + } = this.state; + + const minBytes = minSize * 1024 * 1024; + const minThirty = formatBytes(minBytes * 90, 2); + const minSixty = formatBytes(minBytes * 140, 2); + + const maxBytes = maxSize && maxSize * 1024 * 1024; + const maxThirty = maxBytes ? formatBytes(maxBytes * 90, 2) : 'Unlimited'; + const maxSixty = maxBytes ? formatBytes(maxBytes * 140, 2) : 'Unlimited'; + + return ( +
+
+ {quality.name} +
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+ + { + advancedSettings && +
+
+ Min + + +
+ +
+ Max + + +
+
+ } +
+ ); + } +} + +QualityDefinition.propTypes = { + id: PropTypes.number.isRequired, + quality: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + advancedSettings: PropTypes.bool.isRequired, + onTitleChange: PropTypes.func.isRequired, + onSizeChange: PropTypes.func.isRequired +}; + +export default QualityDefinition; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js new file mode 100644 index 000000000..a76c9440f --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { setQualityDefinitionValue } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import QualityDefinition from './QualityDefinition'; + +const mapDispatchToProps = { + setQualityDefinitionValue, + clearPendingChanges +}; + +class QualityDefinitionConnector extends Component { + + componentWillUnmount() { + this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' }); + } + + // + // Listeners + + onTitleChange = ({ value }) => { + this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value }); + } + + onSizeChange = ({ minSize, maxSize }) => { + const { + id, + minSize: currentMinSize, + maxSize: currentMaxSize + } = this.props; + + if (minSize !== currentMinSize) { + this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize }); + } + + if (maxSize !== currentMaxSize) { + this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionConnector.propTypes = { + id: PropTypes.number.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + setQualityDefinitionValue: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(QualityDefinitionConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css new file mode 100644 index 000000000..689017684 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css @@ -0,0 +1,41 @@ +.header { + display: flex; + font-weight: bold; +} + +.quality, +.title { + flex: 0 1 250px; +} + +.sizeLimit { + flex: 0 1 500px; +} + +.megabytesPerMinute { + flex: 0 0 250px; +} + +.sizeLimitHelpTextContainer { + display: flex; + justify-content: flex-end; + margin-top: 20px; + max-width: 1000px; +} + +.sizeLimitHelpText { + max-width: 500px; + color: $helpTextColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .header { + display: none; + } + + .definitions { + &:first-child { + border-top: none; + } + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js new file mode 100644 index 000000000..c80769c69 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import QualityDefinitionConnector from './QualityDefinitionConnector'; +import styles from './QualityDefinitions.css'; + +class QualityDefinitions extends Component { + + // + // Render + + render() { + const { + items, + advancedSettings, + ...otherProps + } = this.props; + + return ( +
+ +
+
Quality
+
Title
+
Size Limit
+ + { + advancedSettings ? +
+ Megabytes Per Minute +
: + null + } +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+ +
+
+ Limits are automatically adjusted for the movie runtime. +
+
+
+
+ ); + } +} + +QualityDefinitions.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + defaultProfile: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + advancedSettings: PropTypes.bool.isRequired +}; + +export default QualityDefinitions; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js new file mode 100644 index 000000000..c2f830afd --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js @@ -0,0 +1,92 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions'; +import QualityDefinitions from './QualityDefinitions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityDefinitions, + (state) => state.settings.advancedSettings, + (qualityDefinitions, advancedSettings) => { + const items = qualityDefinitions.items.map((item) => { + const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {}; + + return Object.assign({}, item, pendingChanges); + }); + + return { + ...qualityDefinitions, + items, + hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges), + advancedSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityDefinitions: fetchQualityDefinitions, + dispatchSaveQualityDefinitions: saveQualityDefinitions +}; + +class QualityDefinitionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityDefinitions(); + + const { + dispatchFetchQualityDefinitions, + dispatchSaveQualityDefinitions, + onChildMounted + } = this.props; + + dispatchFetchQualityDefinitions(); + onChildMounted(dispatchSaveQualityDefinitions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchQualityDefinitions: PropTypes.func.isRequired, + dispatchSaveQualityDefinitions: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps, null, { withRef: true })(QualityDefinitionsConnector); diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js new file mode 100644 index 000000000..dfd6a24d7 --- /dev/null +++ b/frontend/src/Settings/Quality/Quality.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector'; + +class Quality extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + + ); + } +} + +export default Quality; diff --git a/frontend/src/Settings/Settings.css b/frontend/src/Settings/Settings.css new file mode 100644 index 000000000..497ef47c0 --- /dev/null +++ b/frontend/src/Settings/Settings.css @@ -0,0 +1,18 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + + &:hover { + color: #616573; + text-decoration: none; + } +} + +.summary { + margin-top: 10px; + margin-bottom: 30px; + color: $dimColor; +} diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js new file mode 100644 index 000000000..db114b6a8 --- /dev/null +++ b/frontend/src/Settings/Settings.js @@ -0,0 +1,144 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from './SettingsToolbarConnector'; +import styles from './Settings.css'; + +function Settings() { + return ( + + + + + + Media Management + + +
+ Naming and file management settings +
+ + + Profiles + + +
+ Quality, Language and Delay profiles +
+ + + Quality + + +
+ Quality sizes and naming +
+ + + Indexers + + +
+ Indexers and release restrictions +
+ + + Download Clients + + +
+ Download clients, download handling and remote path mappings +
+ + + Lists + + +
+ Import Lists, list exclusions +
+ + + Connect + + +
+ Notifications, connections to media servers/players and custom scripts +
+ + + Metadata + + +
+ Create metadata files when episodes are imported or series are refreshed +
+ + + Tags + + +
+ See all tags and how they are used. Unused tags can be removed +
+ + + General + + +
+ Port, SSL, username/password, proxy, analytics and updates +
+ + + UI + + +
+ Calendar, date and color impaired options +
+
+
+ ); +} + +Settings.propTypes = { +}; + +export default Settings; diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js new file mode 100644 index 000000000..8b70857d9 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbar.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PendingChangesModal from './PendingChangesModal'; +import AdvancedSettingsButton from './AdvancedSettingsButton'; + +class SettingsToolbar extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true }); + } + + // + // Control + + saveSettings = (event) => { + event.preventDefault(); + + const { + hasPendingChanges, + onSavePress + } = this.props; + + if (hasPendingChanges) { + onSavePress(); + } + } + + // + // Render + + render() { + const { + advancedSettings, + showSave, + isSaving, + hasPendingChanges, + hasPendingLocation, + additionalButtons, + onSavePress, + onConfirmNavigation, + onCancelNavigation, + onAdvancedSettingsPress + } = this.props; + + return ( + + + + + { + showSave && + + } + + { + additionalButtons + } + + + + + ); + } +} + +SettingsToolbar.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + showSave: PropTypes.bool.isRequired, + isSaving: PropTypes.bool, + hasPendingLocation: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool, + additionalButtons: PropTypes.node, + onSavePress: PropTypes.func, + onAdvancedSettingsPress: PropTypes.func.isRequired, + onConfirmNavigation: PropTypes.func.isRequired, + onCancelNavigation: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +SettingsToolbar.defaultProps = { + showSave: true +}; + +export default keyboardShortcuts(SettingsToolbar); diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js new file mode 100644 index 000000000..8bfb3dad5 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbarConnector.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { toggleAdvancedSettings } from 'Store/Actions/settingsActions'; +import SettingsToolbar from './SettingsToolbar'; + +function mapStateToProps(state) { + return { + advancedSettings: state.settings.advancedSettings + }; +} + +const mapDispatchToProps = { + toggleAdvancedSettings +}; + +class SettingsToolbarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + nextLocation: null, + nextLocationAction: null, + confirmed: false + }; + + this._unblock = null; + } + + componentDidMount() { + this._unblock = this.props.history.block(this.routerWillLeave); + } + + componentWillUnmount() { + if (this._unblock) { + this._unblock(); + } + } + + // + // Control + + routerWillLeave = (nextLocation, nextLocationAction) => { + if (this.state.confirmed) { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + + return true; + } + + if (this.props.hasPendingChanges ) { + this.setState({ + nextLocation, + nextLocationAction + }); + + return false; + } + + return true; + } + + // + // Listeners + + onAdvancedSettingsPress = () => { + this.props.toggleAdvancedSettings(); + } + + onConfirmNavigation = () => { + const { + nextLocation, + nextLocationAction + } = this.state; + + const history = this.props.history; + + const path = `${nextLocation.pathname}${nextLocation.search}`; + + this.setState({ + confirmed: true + }, () => { + if (nextLocationAction === 'PUSH') { + history.push(path); + } else { + // Unfortunately back and forward both use POP, + // which means we don't actually know which direction + // the user wanted to go, assuming back. + + history.goBack(); + } + }); + } + + onCancelNavigation = () => { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + } + + // + // Render + + render() { + const hasPendingLocation = this.state.nextLocation !== null; + + return ( + + ); + } +} + +const historyShape = { + block: PropTypes.func.isRequired, + goBack: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.propTypes = { + hasPendingChanges: PropTypes.bool.isRequired, + history: PropTypes.shape(historyShape).isRequired, + onSavePress: PropTypes.func, + toggleAdvancedSettings: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.defaultProps = { + hasPendingChanges: false +}; + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector)); diff --git a/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js new file mode 100644 index 000000000..ab670359b --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import titleCase from 'Utilities/String/titleCase'; + +function TagDetailsDelayProfile(props) { + const { + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = props; + + return ( +
+
+ Protocol: {titleCase(preferredProtocol)} +
+ +
+ { + enableUsenet ? + `Usenet Delay: ${usenetDelay}` : + 'Usenet disabled' + } +
+ +
+ { + enableTorrent ? + `Torrent Delay: ${torrentDelay}` : + 'Torrents disabled' + } +
+
+ ); +} + +TagDetailsDelayProfile.propTypes = { + preferredProtocol: PropTypes.string.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired +}; + +export default TagDetailsDelayProfile; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModal.js b/frontend/src/Settings/Tags/Details/TagDetailsModal.js new file mode 100644 index 000000000..0fe1ec5d3 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import TagDetailsModalContentConnector from './TagDetailsModalContentConnector'; + +function TagDetailsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagDetailsModal; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css new file mode 100644 index 000000000..3488b0509 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css @@ -0,0 +1,26 @@ +.items { + display: flex; + flex-wrap: wrap; +} + +.item { + flex: 0 0 100%; +} + +.restriction { + margin-bottom: 5px; + padding-bottom: 5px; + border-bottom: 1px solid $borderColor; + + &:last-child { + margin: 0; + padding: 0; + border-bottom: none; + } +} + +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js new file mode 100644 index 000000000..27189c731 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Label from 'Components/Label'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import TagDetailsDelayProfile from './TagDetailsDelayProfile'; +import styles from './TagDetailsModalContent.css'; + +function TagDetailsModalContent(props) { + const { + label, + isTagUsed, + movies, + delayProfiles, + notifications, + restrictions, + onModalClose, + onDeleteTagPress + } = props; + + return ( + + + Tag Details - {label} + + + + { + !isTagUsed && +
Tag is not used and can be deleted
+ } + + { + !!movies.length && +
+ { + movies.map((item) => { + return ( +
+ {item.title} +
+ ); + }) + } +
+ } + + { + !!delayProfiles.length && +
+ { + delayProfiles.map((item) => { + const { + id, + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = item; + + return ( + + ); + }) + } +
+ } + + { + !!notifications.length && +
+ { + notifications.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
+ } + + { + !!restrictions.length && +
+ { + restrictions.map((item) => { + return ( +
+
+ { + split(item.required).map((r) => { + return ( + + ); + }) + } +
+ +
+ { + split(item.ignored).map((i) => { + return ( + + ); + }) + } +
+
+ ); + }) + } +
+ } +
+ + + { + + } + + + +
+ ); +} + +TagDetailsModalContent.propTypes = { + label: PropTypes.string.isRequired, + isTagUsed: PropTypes.bool.isRequired, + movies: PropTypes.arrayOf(PropTypes.object).isRequired, + delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + notifications: PropTypes.arrayOf(PropTypes.object).isRequired, + restrictions: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteTagPress: PropTypes.func.isRequired +}; + +export default TagDetailsModalContent; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js new file mode 100644 index 000000000..414fa0ff4 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -0,0 +1,61 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import TagDetailsModalContent from './TagDetailsModalContent'; + +function findMatchingItems(ids, items) { + return items.filter((s) => { + return ids.includes(s.id); + }); +} + +function createMatchingMovieSelector() { + return createSelector( + (state, { movieIds }) => movieIds, + createAllMoviesSelector(), + findMatchingItems + ); +} + +function createMatchingDelayProfilesSelector() { + return createSelector( + (state, { delayProfileIds }) => delayProfileIds, + (state) => state.settings.delayProfiles.items, + findMatchingItems + ); +} + +function createMatchingNotificationsSelector() { + return createSelector( + (state, { notificationIds }) => notificationIds, + (state) => state.settings.notifications.items, + findMatchingItems + ); +} + +function createMatchingRestrictionsSelector() { + return createSelector( + (state, { restrictionIds }) => restrictionIds, + (state) => state.settings.restrictions.items, + findMatchingItems + ); +} + +function createMapStateToProps() { + return createSelector( + createMatchingMovieSelector(), + createMatchingDelayProfilesSelector(), + createMatchingNotificationsSelector(), + createMatchingRestrictionsSelector(), + (movies, delayProfiles, notifications, restrictions) => { + return { + movies, + delayProfiles, + notifications, + restrictions + }; + } + ); +} + +export default connect(createMapStateToProps)(TagDetailsModalContent); diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css new file mode 100644 index 000000000..ee425e309 --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.css @@ -0,0 +1,11 @@ +.tag { + composes: card from 'Components/Card.css'; + + width: 150px; +} + +.label { + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js new file mode 100644 index 000000000..0d02ef62f --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagDetailsModal from './Details/TagDetailsModal'; +import styles from './Tag.css'; + +class Tag extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isDeleteTagModalOpen: false + }; + } + + // + // Listeners + + onShowDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onDeleteTagPress = () => { + this.setState({ + isDetailsModalOpen: false, + isDeleteTagModalOpen: true + }); + } + + onDeleteTagModalClose= () => { + this.setState({ isDeleteTagModalOpen: false }); + } + + onConfirmDeleteTag = () => { + this.props.onConfirmDeleteTag({ id: this.props.id }); + } + + // + // Render + + render() { + const { + label, + delayProfileIds, + notificationIds, + restrictionIds, + movieIds + } = this.props; + + const { + isDetailsModalOpen, + isDeleteTagModalOpen + } = this.state; + + const isTagUsed = !!( + delayProfileIds.length || + notificationIds.length || + restrictionIds.length || + movieIds.length + ); + + return ( + +
+ {label} +
+ + { + isTagUsed && +
+ { + !!movieIds.length && +
+ {movieIds.length} movies +
+ } + + { + !!delayProfileIds.length && +
+ {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} +
+ } + + { + !!notificationIds.length && +
+ {notificationIds.length} connection{notificationIds.length > 1 && 's'} +
+ } + + { + !!restrictionIds.length && +
+ {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'} +
+ } +
+ } + + { + !isTagUsed && +
+ No links +
+ } + + + + +
+ ); + } +} + +Tag.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired, + notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired, + restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, + movieIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onConfirmDeleteTag: PropTypes.func.isRequired +}; + +Tag.defaultProps = { + delayProfileIds: [], + notificationIds: [], + restrictionIds: [], + movieIds: [] +}; + +export default Tag; diff --git a/frontend/src/Settings/Tags/TagConnector.js b/frontend/src/Settings/Tags/TagConnector.js new file mode 100644 index 000000000..50f610153 --- /dev/null +++ b/frontend/src/Settings/Tags/TagConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector'; +import { deleteTag } from 'Store/Actions/tagActions'; +import Tag from './Tag'; + +function createMapStateToProps() { + return createSelector( + createTagDetailsSelector(), + (tagDetails) => { + return { + ...tagDetails + }; + } + ); +} + +const mapStateToProps = { + onConfirmDeleteTag: deleteTag +}; + +export default connect(createMapStateToProps, mapStateToProps)(Tag); diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js new file mode 100644 index 000000000..56ef92b49 --- /dev/null +++ b/frontend/src/Settings/Tags/TagSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import TagsConnector from './TagsConnector'; + +function TagSettings() { + return ( + + + + + + + + ); +} + +export default TagSettings; diff --git a/frontend/src/Settings/Tags/Tags.css b/frontend/src/Settings/Tags/Tags.css new file mode 100644 index 000000000..5a44f8331 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.css @@ -0,0 +1,4 @@ +.tags { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/Tags.js b/frontend/src/Settings/Tags/Tags.js new file mode 100644 index 000000000..e1375ba76 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import TagConnector from './TagConnector'; +import styles from './Tags.css'; + +function Tags(props) { + const { + items, + ...otherProps + } = props; + + if (!items.length) { + return ( +
No tags have been added yet
+ ); + } + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+
+ ); +} + +Tags.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Tags; diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js new file mode 100644 index 000000000..dccf9b43d --- /dev/null +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTagDetails } from 'Store/Actions/tagActions'; +import { fetchDelayProfiles, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions'; +import Tags from './Tags'; + +function createMapStateToProps() { + return createSelector( + (state) => state.tags, + (tags) => { + const isFetching = tags.isFetching || tags.details.isFetching; + const error = tags.error || tags.details.error; + const isPopulated = tags.isPopulated && tags.details.isPopulated; + + return { + ...tags, + isFetching, + error, + isPopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTagDetails: fetchTagDetails, + dispatchFetchDelayProfiles: fetchDelayProfiles, + dispatchFetchNotifications: fetchNotifications, + dispatchFetchRestrictions: fetchRestrictions +}; + +class MetadatasConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchTagDetails, + dispatchFetchDelayProfiles, + dispatchFetchNotifications, + dispatchFetchRestrictions + } = this.props; + + dispatchFetchTagDetails(); + dispatchFetchDelayProfiles(); + dispatchFetchNotifications(); + dispatchFetchRestrictions(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadatasConnector.propTypes = { + dispatchFetchTagDetails: PropTypes.func.isRequired, + dispatchFetchDelayProfiles: PropTypes.func.isRequired, + dispatchFetchNotifications: PropTypes.func.isRequired, + dispatchFetchRestrictions: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js new file mode 100644 index 000000000..71356a5f0 --- /dev/null +++ b/frontend/src/Settings/UI/UISettings.js @@ -0,0 +1,195 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +export const firstDayOfWeekOptions = [ + { key: 0, value: 'Sunday' }, + { key: 1, value: 'Monday' } +]; + +export const weekColumnOptions = [ + { key: 'ddd M/D', value: 'Tue 3/25' }, + { key: 'ddd MM/DD', value: 'Tue 03/25' }, + { key: 'ddd D/M', value: 'Tue 25/03' }, + { key: 'ddd DD/MM', value: 'Tue 25/03' } +]; + +const shortDateFormatOptions = [ + { key: 'MMM D YYYY', value: 'Mar 25 2014' }, + { key: 'DD MMM YYYY', value: '25 Mar 2014' }, + { key: 'MM/D/YYYY', value: '03/25/2014' }, + { key: 'MM/DD/YYYY', value: '03/25/2014' }, + { key: 'DD/MM/YYYY', value: '25/03/2014' }, + { key: 'YYYY-MM-DD', value: '2014-03-25' } +]; + +const longDateFormatOptions = [ + { key: 'dddd, MMMM D YYYY', value: 'Tuesday, March 25, 2014' }, + { key: 'dddd, D MMMM YYYY', value: 'Tuesday, 25 March, 2014' } +]; + +export const timeFormatOptions = [ + { key: 'h(:mm)a', value: '5pm/5:30pm' }, + { key: 'HH:mm', value: '17:00/17:30' } +]; + +class UISettings extends Component { + + // + // Render + + render() { + const { + isFetching, + error, + settings, + hasSettings, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && + + } + + { + !isFetching && error && +
Unable to load UI settings
+ } + + { + hasSettings && !isFetching && !error && +
+
+ + First Day of Week + + + + + + Week Column Header + + + +
+ +
+ + Short Date Format + + + + + + Long Date Format + + + + + + Time Format + + + + + + Show Relative Dates + + +
+ +
+ + Enable Color-Impaired Mode + + +
+
+ } +
+
+ ); + } + +} + +UISettings.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UISettings; diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js new file mode 100644 index 000000000..ac8c9f8f1 --- /dev/null +++ b/frontend/src/Settings/UI/UISettingsConnector.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { setUISettingsValue, saveUISettings, fetchUISettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import UISettings from './UISettings'; + +const SECTION = 'ui'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setUISettingsValue, + saveUISettings, + fetchUISettings, + clearPendingChanges +}; + +class UISettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUISettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: `settings.${SECTION}` }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setUISettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveUISettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UISettingsConnector.propTypes = { + setUISettingsValue: PropTypes.func.isRequired, + saveUISettings: PropTypes.func.isRequired, + fetchUISettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector); diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js new file mode 100644 index 000000000..aadc9cec7 --- /dev/null +++ b/frontend/src/Shared/piwikCheck.js @@ -0,0 +1,11 @@ +if (window.Radarr.analytics) { + const d = document; + const g = d.createElement('script'); + const s = d.getElementsByTagName('script')[0]; + + g.type = 'text/javascript'; + g.async = true; + g.defer = true; + g.src = '//piwik.sonarr.tv/piwik.js'; + s.parentNode.insertBefore(g, s); +} diff --git a/frontend/src/Shims/jquery.js b/frontend/src/Shims/jquery.js new file mode 100644 index 000000000..d0234889c --- /dev/null +++ b/frontend/src/Shims/jquery.js @@ -0,0 +1,10 @@ +import $ from 'jquery'; +import ajax from 'jQuery/jquery.ajax'; + +ajax($); + +const jquery = $; +window.$ = $; +window.jQuery = $; + +export default jquery; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js new file mode 100644 index 000000000..2952973a9 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js @@ -0,0 +1,12 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createClearReducer(section, defaultState) { + return (state) => { + const newState = Object.assign(getSectionState(state, section), defaultState); + + return updateSectionState(state, section, newState); + }; +} + +export default createClearReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js new file mode 100644 index 000000000..d58bb1cd4 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js @@ -0,0 +1,14 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionFilterReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + newState.selectedFilterKey = payload.selectedFilterKey; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionFilterReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js new file mode 100644 index 000000000..1bc048a80 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js @@ -0,0 +1,29 @@ +import { sortDirections } from 'Helpers/Props'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionSortReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + const sortKey = payload.sortKey || newState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === newState.sortKey) { + sortDirection = newState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = newState.sortDirection; + } + } + + newState.sortKey = sortKey; + newState.sortDirection = sortDirection; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionSortReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js new file mode 100644 index 000000000..3af58dd3b --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js @@ -0,0 +1,23 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetProviderFieldValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + const fields = Object.assign({}, newState.pendingChanges.fields || {}); + + fields[name] = value; + + newState.pendingChanges.fields = fields; + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetProviderFieldValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js new file mode 100644 index 000000000..474eb7bb2 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetSettingValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + + const currentValue = newState.item ? newState.item[name] : null; + const pendingState = newState.pendingChanges; + + let parsedValue = null; + + if (_.isNumber(currentValue) && value != null) { + parsedValue = parseInt(value); + } else { + parsedValue = value; + } + + if (currentValue === parsedValue) { + delete pendingState[name]; + } else { + pendingState[name] = parsedValue; + } + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetSettingValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js new file mode 100644 index 000000000..70b57446d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +const whitelistedProperties = [ + 'pageSize', + 'columns', + 'tableOptions' +]; + +function createSetTableOptionReducer(section) { + return (state, { payload }) => { + const newState = Object.assign( + getSectionState(state, section), + _.pick(payload, whitelistedProperties)); + + return updateSectionState(state, section, newState); + }; +} + +export default createSetTableOptionReducer; diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js new file mode 100644 index 000000000..87be89828 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; +import updateEpisodes from 'Utilities/Episode/updateEpisodes'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const { + episodeIds, + monitored + } = payload; + + const state = getSectionState(getState(), section, true); + + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: true + })); + + const promise = $.ajax({ + url: '/episode/monitor', + method: 'PUT', + data: JSON.stringify({ episodeIds, monitored }), + dataType: 'json' + }); + + promise.done(() => { + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: false, + monitored + })); + + dispatch(fetchHandler()); + }); + + promise.fail(() => { + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: false + })); + }); + }; +} + +export default createBatchToggleEpisodeMonitoredHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js new file mode 100644 index 000000000..c9cd058bd --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js @@ -0,0 +1,44 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update, updateItem } from '../baseActions'; + +export default function createFetchHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + id, + ...otherPayload + } = payload; + + const { request, abortRequest } = createAjaxRequest({ + url: id == null ? url : `${url}/${id}`, + data: otherPayload, + traditional: true + }); + + request.done((data) => { + dispatch(batchActions([ + id == null ? update({ section, data }) : updateItem({ section, ...data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + + return abortRequest; + }; +} diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js new file mode 100644 index 000000000..2c02c8c20 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js @@ -0,0 +1,33 @@ +import $ from 'jquery'; +import { set } from '../baseActions'; + +function createFetchSchemaHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSchemaFetching: true })); + + const promise = $.ajax({ + url + }); + + promise.done((data) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: null, + schema: data + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: xhr + })); + }); + }; +} + +export default createFetchSchemaHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js new file mode 100644 index 000000000..e7f1d4b04 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, updateServerSideCollection } from '../baseActions'; + +function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const sectionState = getSectionState(getState(), section, true); + const page = payload.page || sectionState.page || 1; + + const data = Object.assign({ page }, + _.pick(sectionState, [ + 'pageSize', + 'sortDirection', + 'sortKey' + ])); + + if (fetchDataAugmenter) { + fetchDataAugmenter(getState, payload, data); + } + + const { + selectedFilterKey, + filters, + customFilters + } = sectionState; + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + selectedFilters.forEach((filter) => { + data[filter.key] = filter.value; + }); + + const promise = $.ajax({ + url, + data + }); + + promise.done((response) => { + dispatch(batchActions([ + updateServerSideCollection({ section, data: response }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }; +} + +export default createFetchServerSideCollectionHandler; diff --git a/frontend/src/Store/Actions/Creators/createHandleActions.js b/frontend/src/Store/Actions/Creators/createHandleActions.js new file mode 100644 index 000000000..c3315ce94 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createHandleActions.js @@ -0,0 +1,143 @@ +import _ from 'lodash'; +import { handleActions } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { + SET, + UPDATE, + UPDATE_ITEM, + UPDATE_SERVER_SIDE_COLLECTION, + CLEAR_PENDING_CHANGES, + REMOVE_ITEM +} from 'Store/Actions/baseActions'; + +const blacklistedProperties = [ + 'section', + 'id' +]; + +export default function createHandleActions(handlers, defaultState, section) { + return handleActions({ + + [SET]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = Object.assign(getSectionState(state, payloadSection), + _.omit(payload, blacklistedProperties)); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + if (_.isArray(payload.data)) { + newState.items = payload.data; + } else { + newState.item = payload.data; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_ITEM]: function(state, { payload }) { + const { + section: payloadSection, + updateOnly = false, + ...otherProps + } = payload; + + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + // TODO: Move adding to it's own reducer + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...otherProps }); + } else if (!updateOnly) { + newState.items.push({ ...otherProps }); + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [CLEAR_PENDING_CHANGES]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + newState.pendingChanges = {}; + + if (newState.hasOwnProperty('saveError')) { + newState.saveError = null; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [REMOVE_ITEM]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_SERVER_SIDE_COLLECTION]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const data = payload.data; + const newState = getSectionState(state, payloadSection); + + const serverState = _.omit(data, ['records']); + const calculatedState = { + totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1), + items: data.records + }; + + return updateSectionState(state, payloadSection, Object.assign(newState, serverState, calculatedState)); + } + + return state; + }, + + ...handlers + + }, defaultState); +} diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js new file mode 100644 index 000000000..6f353ed17 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -0,0 +1,45 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { set, removeItem } from '../baseActions'; + +function createRemoveItemHandler(section, url) { + return function(getState, payload, dispatch) { + const { + id, + ...queryParams + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const ajaxOptions = { + url: `${url}/${id}?${$.param(queryParams, true)}`, + method: 'DELETE' + }; + + const promise = $.ajax(ajaxOptions); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isDeleting: false, + deleteError: null + }), + + removeItem({ section, id }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + + return promise; + }; +} + +export default createRemoveItemHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js new file mode 100644 index 000000000..e63c9f993 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js @@ -0,0 +1,43 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, update } from '../baseActions'; + +function createSaveHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const state = getSectionState(getState(), section, true); + const saveData = Object.assign({}, state.item, state.pendingChanges, payload); + + const promise = $.ajax({ + url, + method: 'PUT', + dataType: 'json', + data: JSON.stringify(saveData) + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }; +} + +export default createSaveHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js new file mode 100644 index 000000000..6d555bf23 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js @@ -0,0 +1,70 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set, updateItem } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelSaveProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createSaveProviderHandler(section, url, options = {}) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const { + id, + queryParams = {}, + ...otherPayload + } = payload; + + const saveData = getProviderState({ id, ...otherPayload }, getState, section); + + const ajaxOptions = { + url: `${url}?${$.param(queryParams, true)}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(saveData) + }; + + if (id) { + ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`; + ajaxOptions.method = 'PUT'; + } + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + dispatch(batchActions([ + updateItem({ section, ...data }), + + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createSaveProviderHandler; diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js new file mode 100644 index 000000000..f81723769 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js @@ -0,0 +1,52 @@ +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import pages from 'Utilities/pages'; +import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler'; +import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler'; +import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler'; +import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler'; + +function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) { + const actionHandlers = {}; + const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH]; + const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter); + actionHandlers[fetchHandlerType] = fetchHandler; + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) { + const handlerType = handlers[serverSideCollectionHandlers.SORT]; + actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) { + const handlerType = handlers[serverSideCollectionHandlers.FILTER]; + actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, fetchThunk); + } + + return actionHandlers; +} + +export default createServerSideCollectionHandlers; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js new file mode 100644 index 000000000..d7e476444 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js @@ -0,0 +1,10 @@ +import { set } from '../baseActions'; + +function createSetServerSideCollectionFilterHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + dispatch(set({ section, ...payload })); + dispatch(fetchHandler({ page: 1 })); + }; +} + +export default createSetServerSideCollectionFilterHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js new file mode 100644 index 000000000..12b21bb0d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js @@ -0,0 +1,35 @@ +import pages from 'Utilities/pages'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createSetServerSideCollectionPageHandler(section, page, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const currentPage = sectionState.page || 1; + let nextPage = 0; + + switch (page) { + case pages.FIRST: + nextPage = 1; + break; + case pages.PREVIOUS: + nextPage = currentPage - 1; + break; + case pages.NEXT: + nextPage = currentPage + 1; + break; + case pages.LAST: + nextPage = sectionState.totalPages; + break; + default: + nextPage = payload.page; + } + + // If we prefer to update the page immediately we should + // set the page and not pass a page to the fetch handler. + + // dispatch(set({ section, page: nextPage })); + dispatch(fetchHandler({ page: nextPage })); + }; +} + +export default createSetServerSideCollectionPageHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js new file mode 100644 index 000000000..fbd66e83e --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js @@ -0,0 +1,26 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import { sortDirections } from 'Helpers/Props'; +import { set } from '../baseActions'; + +function createSetServerSideCollectionSortHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const sortKey = payload.sortKey || sectionState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === sectionState.sortKey) { + sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = sectionState.sortDirection; + } + } + + dispatch(set({ section, sortKey, sortDirection })); + dispatch(fetchHandler()); + }; +} + +export default createSetServerSideCollectionSortHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js new file mode 100644 index 000000000..77deaec64 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js @@ -0,0 +1,34 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set } from '../baseActions'; + +function createTestAllProvidersHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTestingAll: true })); + + const ajaxOptions = { + url: `${url}/testall`, + method: 'POST', + contentType: 'application/json', + dataType: 'json' + }; + + const { request } = createAjaxRequest(ajaxOptions); + + request.done((data) => { + dispatch(set({ + section, + isTestingAll: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTestingAll: false + })); + }); + }; +} + +export default createTestAllProvidersHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js new file mode 100644 index 000000000..ca26883fb --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js @@ -0,0 +1,52 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelTestProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createTestProviderHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTesting: true })); + + const testData = getProviderState(payload, getState, section); + + const ajaxOptions = { + url: `${url}/test`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(testData) + }; + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + dispatch(set({ + section, + isTesting: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTesting: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createTestProviderHandler; diff --git a/frontend/src/Store/Actions/Settings/delayProfiles.js b/frontend/src/Store/Actions/Settings/delayProfiles.js new file mode 100644 index 000000000..68232e510 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/delayProfiles.js @@ -0,0 +1,103 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import { update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.delayProfiles'; + +// +// Actions Types + +export const FETCH_DELAY_PROFILES = 'settings/delayProfiles/fetchDelayProfiles'; +export const FETCH_DELAY_PROFILE_SCHEMA = 'settings/delayProfiles/fetchDelayProfileSchema'; +export const SAVE_DELAY_PROFILE = 'settings/delayProfiles/saveDelayProfile'; +export const DELETE_DELAY_PROFILE = 'settings/delayProfiles/deleteDelayProfile'; +export const REORDER_DELAY_PROFILE = 'settings/delayProfiles/reorderDelayProfile'; +export const SET_DELAY_PROFILE_VALUE = 'settings/delayProfiles/setDelayProfileValue'; + +// +// Action Creators + +export const fetchDelayProfiles = createThunk(FETCH_DELAY_PROFILES); +export const fetchDelayProfileSchema = createThunk(FETCH_DELAY_PROFILE_SCHEMA); +export const saveDelayProfile = createThunk(SAVE_DELAY_PROFILE); +export const deleteDelayProfile = createThunk(DELETE_DELAY_PROFILE); +export const reorderDelayProfile = createThunk(REORDER_DELAY_PROFILE); + +export const setDelayProfileValue = createAction(SET_DELAY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DELAY_PROFILES]: createFetchHandler(section, '/delayprofile'), + [FETCH_DELAY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/delayprofile/schema'), + + [SAVE_DELAY_PROFILE]: createSaveProviderHandler(section, '/delayprofile'), + [DELETE_DELAY_PROFILE]: createRemoveItemHandler(section, '/delayprofile'), + + [REORDER_DELAY_PROFILE]: (getState, payload, dispatch) => { + const { id, moveIndex } = payload; + const moveOrder = moveIndex + 1; + const delayProfiles = getState().settings.delayProfiles.items; + const moving = _.find(delayProfiles, { id }); + + // Don't move if the order hasn't changed + if (moving.order === moveOrder) { + return; + } + + const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null; + const afterQueryParam = after ? `after=${after.id}` : ''; + + const promise = $.ajax({ + method: 'PUT', + url: `/delayprofile/reorder/${id}?${afterQueryParam}` + }); + + promise.done((data) => { + dispatch(update({ section, data })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClientOptions.js b/frontend/src/Store/Actions/Settings/downloadClientOptions.js new file mode 100644 index 000000000..6d4a3954d --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClientOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.downloadClientOptions'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS'; +export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE'; +export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS'; + +// +// Action Creators + +export const fetchDownloadClientOptions = createThunk(FETCH_DOWNLOAD_CLIENT_OPTIONS); +export const saveDownloadClientOptions = createThunk(SAVE_DOWNLOAD_CLIENT_OPTIONS); +export const setDownloadClientOptionsValue = createAction(SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler(section, '/config/downloadclient'), + [SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler(section, '/config/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js new file mode 100644 index 000000000..a268053f7 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -0,0 +1,117 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.downloadClients'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients'; +export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema'; +export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema'; +export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue'; +export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue'; +export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient'; +export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient'; +export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient'; +export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient'; +export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; +export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; + +// +// Action Creators + +export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS); +export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA); +export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA); + +export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT); +export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT); +export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT); +export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); +export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); +export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); + +export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'), + [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'), + + [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'), + [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section), + [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), + [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), + [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section), + [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enable = true; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js new file mode 100644 index 000000000..f5e8c277e --- /dev/null +++ b/frontend/src/Store/Actions/Settings/general.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.general'; + +// +// Actions Types + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; + +// +// Action Creators + +export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS); +export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS); +export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'), + [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host') + }, + + // + // Reducers + + reducers: { + [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js new file mode 100644 index 000000000..53fb21651 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexerOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.indexerOptions'; + +// +// Actions Types + +export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions'; +export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions'; +export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue'; + +// +// Action Creators + +export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS); +export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS); +export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'), + [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js new file mode 100644 index 000000000..5c1b0a508 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -0,0 +1,118 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.indexers'; + +// +// Actions Types + +export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers'; +export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema'; +export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema'; +export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue'; +export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue'; +export const SAVE_INDEXER = 'settings/indexers/saveIndexer'; +export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer'; +export const DELETE_INDEXER = 'settings/indexers/deleteIndexer'; +export const TEST_INDEXER = 'settings/indexers/testIndexer'; +export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; +export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; + +// +// Action Creators + +export const fetchIndexers = createThunk(FETCH_INDEXERS); +export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); +export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); + +export const saveIndexer = createThunk(SAVE_INDEXER); +export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER); +export const deleteIndexer = createThunk(DELETE_INDEXER); +export const testIndexer = createThunk(TEST_INDEXER); +export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); +export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); + +export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'), + [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'), + + [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'), + [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section), + [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), + [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), + [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_VALUE]: createSetSettingValueReducer(section), + [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enableRss = selectedSchema.supportsRss; + selectedSchema.enableSearch = selectedSchema.supportsSearch; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/mediaManagement.js b/frontend/src/Store/Actions/Settings/mediaManagement.js new file mode 100644 index 000000000..4ae9eba0c --- /dev/null +++ b/frontend/src/Store/Actions/Settings/mediaManagement.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.mediaManagement'; + +// +// Actions Types + +export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/fetchMediaManagementSettings'; +export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/saveMediaManagementSettings'; +export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'settings/mediaManagement/setMediaManagementSettingsValue'; + +// +// Action Creators + +export const fetchMediaManagementSettings = createThunk(FETCH_MEDIA_MANAGEMENT_SETTINGS); +export const saveMediaManagementSettings = createThunk(SAVE_MEDIA_MANAGEMENT_SETTINGS); +export const setMediaManagementSettingsValue = createAction(SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler(section, '/config/mediamanagement'), + [SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler(section, '/config/mediamanagement') + }, + + // + // Reducers + + reducers: { + [SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/metadata.js b/frontend/src/Store/Actions/Settings/metadata.js new file mode 100644 index 000000000..ed5e0aa86 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/metadata.js @@ -0,0 +1,75 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; + +// +// Variables + +const section = 'settings.metadata'; + +// +// Actions Types + +export const FETCH_METADATA = 'settings/metadata/fetchMetadata'; +export const SET_METADATA_VALUE = 'settings/metadata/setMetadataValue'; +export const SET_METADATA_FIELD_VALUE = 'settings/metadata/setMetadataFieldValue'; +export const SAVE_METADATA = 'settings/metadata/saveMetadata'; + +// +// Action Creators + +export const fetchMetadata = createThunk(FETCH_METADATA); +export const saveMetadata = createThunk(SAVE_METADATA); + +export const setMetadataValue = createAction(SET_METADATA_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setMetadataFieldValue = createAction(SET_METADATA_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_METADATA]: createFetchHandler(section, '/metadata'), + [SAVE_METADATA]: createSaveProviderHandler(section, '/metadata') + }, + + // + // Reducers + + reducers: { + [SET_METADATA_VALUE]: createSetSettingValueReducer(section), + [SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/naming.js b/frontend/src/Store/Actions/Settings/naming.js new file mode 100644 index 000000000..27add8309 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/naming.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.naming'; + +// +// Actions Types + +export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings'; +export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings'; +export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue'; + +// +// Action Creators + +export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS); +export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS); +export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'), + [SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming') + }, + + // + // Reducers + + reducers: { + [SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/namingExamples.js b/frontend/src/Store/Actions/Settings/namingExamples.js new file mode 100644 index 000000000..e3f2ae01c --- /dev/null +++ b/frontend/src/Store/Actions/Settings/namingExamples.js @@ -0,0 +1,79 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk } from 'Store/thunks'; +import { set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.namingExamples'; + +// +// Actions Types + +export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples'; + +// +// Action Creators + +export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const naming = getState().settings.naming; + + const promise = $.ajax({ + url: '/config/naming/examples', + data: Object.assign({}, naming.item, naming.pendingChanges) + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: {} + +}; diff --git a/frontend/src/Store/Actions/Settings/netImportOptions.js b/frontend/src/Store/Actions/Settings/netImportOptions.js new file mode 100644 index 000000000..06a6a13d9 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/netImportOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.netImportOptions'; + +// +// Actions Types + +export const FETCH_NET_IMPORT_OPTIONS = 'settings/netImportOptions/fetchNetImportOptions'; +export const SAVE_NET_IMPORT_OPTIONS = 'settings/netImportOptions/saveNetImportOptions'; +export const SET_NET_IMPORT_OPTIONS_VALUE = 'settings/netImportOptions/setNetImportOptionsValue'; + +// +// Action Creators + +export const fetchNetImportOptions = createThunk(FETCH_NET_IMPORT_OPTIONS); +export const saveNetImportOptions = createThunk(SAVE_NET_IMPORT_OPTIONS); +export const setNetImportOptionsValue = createAction(SET_NET_IMPORT_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NET_IMPORT_OPTIONS]: createFetchHandler(section, '/config/netimport'), + [SAVE_NET_IMPORT_OPTIONS]: createSaveHandler(section, '/config/netimport') + }, + + // + // Reducers + + reducers: { + [SET_NET_IMPORT_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/netImports.js b/frontend/src/Store/Actions/Settings/netImports.js new file mode 100644 index 000000000..37125af2b --- /dev/null +++ b/frontend/src/Store/Actions/Settings/netImports.js @@ -0,0 +1,116 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.netImports'; + +// +// Actions Types + +export const FETCH_NET_IMPORTS = 'settings/netImports/fetchNetImports'; +export const FETCH_NET_IMPORT_SCHEMA = 'settings/netImports/fetchNetImportSchema'; +export const SELECT_NET_IMPORT_SCHEMA = 'settings/netImports/selectNetImportSchema'; +export const SET_NET_IMPORT_VALUE = 'settings/netImports/setNetImportValue'; +export const SET_NET_IMPORT_FIELD_VALUE = 'settings/netImports/setNetImportFieldValue'; +export const SAVE_NET_IMPORT = 'settings/netImports/saveNetImport'; +export const CANCEL_SAVE_NET_IMPORT = 'settings/netImports/cancelSaveNetImport'; +export const DELETE_NET_IMPORT = 'settings/netImports/deleteNetImport'; +export const TEST_NET_IMPORT = 'settings/netImports/testNetImport'; +export const CANCEL_TEST_NET_IMPORT = 'settings/netImports/cancelTestNetImport'; +export const TEST_ALL_NET_IMPORT = 'settings/netImports/testAllNetImport'; + +// +// Action Creators + +export const fetchNetImports = createThunk(FETCH_NET_IMPORTS); +export const fetchNetImportSchema = createThunk(FETCH_NET_IMPORT_SCHEMA); +export const selectNetImportSchema = createAction(SELECT_NET_IMPORT_SCHEMA); + +export const saveNetImport = createThunk(SAVE_NET_IMPORT); +export const cancelSaveNetImport = createThunk(CANCEL_SAVE_NET_IMPORT); +export const deleteNetImport = createThunk(DELETE_NET_IMPORT); +export const testNetImport = createThunk(TEST_NET_IMPORT); +export const cancelTestNetImport = createThunk(CANCEL_TEST_NET_IMPORT); +export const testAllNetImport = createThunk(TEST_ALL_NET_IMPORT); + +export const setNetImportValue = createAction(SET_NET_IMPORT_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setNetImportFieldValue = createAction(SET_NET_IMPORT_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NET_IMPORTS]: createFetchHandler(section, '/netimport'), + [FETCH_NET_IMPORT_SCHEMA]: createFetchSchemaHandler(section, '/netimport/schema'), + + [SAVE_NET_IMPORT]: createSaveProviderHandler(section, '/netimport'), + [CANCEL_SAVE_NET_IMPORT]: createCancelSaveProviderHandler(section), + [DELETE_NET_IMPORT]: createRemoveItemHandler(section, '/netimport'), + [TEST_NET_IMPORT]: createTestProviderHandler(section, '/netimport'), + [CANCEL_TEST_NET_IMPORT]: createCancelTestProviderHandler(section), + [TEST_ALL_NET_IMPORT]: createTestAllProvidersHandler(section, '/netimport') + }, + + // + // Reducers + + reducers: { + [SET_NET_IMPORT_VALUE]: createSetSettingValueReducer(section), + [SET_NET_IMPORT_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_NET_IMPORT_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js new file mode 100644 index 000000000..b2c28dac9 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -0,0 +1,115 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.notifications'; + +// +// Actions Types + +export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications'; +export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema'; +export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema'; +export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue'; +export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue'; +export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification'; +export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification'; +export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification'; +export const TEST_NOTIFICATION = 'settings/notifications/testNotification'; +export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification'; + +// +// Action Creators + +export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS); +export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA); +export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA); + +export const saveNotification = createThunk(SAVE_NOTIFICATION); +export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION); +export const deleteNotification = createThunk(DELETE_NOTIFICATION); +export const testNotification = createThunk(TEST_NOTIFICATION); +export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION); + +export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'), + [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'), + + [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'), + [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section), + [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'), + [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'), + [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section) + }, + + // + // Reducers + + reducers: { + [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section), + [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.onGrab = selectedSchema.supportsOnGrab; + selectedSchema.onDownload = selectedSchema.supportsOnDownload; + selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; + selectedSchema.onRename = selectedSchema.supportsOnRename; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js new file mode 100644 index 000000000..fb6572f45 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk } from 'Store/thunks'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; +import { clearPendingChanges, set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.qualityDefinitions'; + +// +// Actions Types + +export const FETCH_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/fetchQualityDefinitions'; +export const SAVE_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/saveQualityDefinitions'; +export const SET_QUALITY_DEFINITION_VALUE = 'settings/qualityDefinitions/setQualityDefinitionValue'; + +// +// Action Creators + +export const fetchQualityDefinitions = createThunk(FETCH_QUALITY_DEFINITIONS); +export const saveQualityDefinitions = createThunk(SAVE_QUALITY_DEFINITIONS); + +export const setQualityDefinitionValue = createAction(SET_QUALITY_DEFINITION_VALUE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_DEFINITIONS]: createFetchHandler(section, '/qualitydefinition'), + [SAVE_QUALITY_DEFINITIONS]: createSaveHandler(section, '/qualitydefinition'), + + [SAVE_QUALITY_DEFINITIONS]: function(getState, payload, dispatch) { + const qualityDefinitions = getState().settings.qualityDefinitions; + + const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => { + const id = parseInt(key); + const pendingChanges = qualityDefinitions.pendingChanges[id] || {}; + const item = _.find(qualityDefinitions.items, { id }); + + return Object.assign({}, item, pendingChanges); + }); + + // If there is nothing to save don't bother isSaving + if (!upatedDefinitions || !upatedDefinitions.length) { + return; + } + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + method: 'PUT', + url: '/qualityDefinition/update', + data: JSON.stringify(upatedDefinitions) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isSaving: false, + saveError: null + }), + + update({ section, data }), + clearPendingChanges({ section }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) { + const { id, name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = _.cloneDeep(newState.pendingChanges); + + const pendingState = newState.pendingChanges[id] || {}; + const currentValue = _.find(newState.items, { id })[name]; + + if (currentValue === value) { + delete pendingState[name]; + } else { + pendingState[name] = value; + } + + if (_.isEmpty(pendingState)) { + delete newState.pendingChanges[id]; + } else { + newState.pendingChanges[id] = pendingState; + } + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js new file mode 100644 index 000000000..6fdc204a0 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.qualityProfiles'; + +// +// Actions Types + +export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles'; +export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema'; +export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile'; +export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile'; +export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue'; +export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile'; + +// +// Action Creators + +export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES); +export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA); +export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE); +export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE); + +export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'), + [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'), + [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'), + [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile') + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_QUALITY_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js new file mode 100644 index 000000000..3cfcc7f1f --- /dev/null +++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js @@ -0,0 +1,69 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.remotePathMappings'; + +// +// Actions Types + +export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings'; +export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping'; +export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping'; +export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue'; + +// +// Action Creators + +export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS); +export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING); +export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING); + +export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'), + [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'), + [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping') + }, + + // + // Reducers + + reducers: { + [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/restrictions.js b/frontend/src/Store/Actions/Settings/restrictions.js new file mode 100644 index 000000000..190b5124e --- /dev/null +++ b/frontend/src/Store/Actions/Settings/restrictions.js @@ -0,0 +1,71 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.restrictions'; + +// +// Actions Types + +export const FETCH_RESTRICTIONS = 'settings/restrictions/fetchRestrictions'; +export const SAVE_RESTRICTION = 'settings/restrictions/saveRestriction'; +export const DELETE_RESTRICTION = 'settings/restrictions/deleteRestriction'; +export const SET_RESTRICTION_VALUE = 'settings/restrictions/setRestrictionValue'; + +// +// Action Creators + +export const fetchRestrictions = createThunk(FETCH_RESTRICTIONS); +export const saveRestriction = createThunk(SAVE_RESTRICTION); +export const deleteRestriction = createThunk(DELETE_RESTRICTION); + +export const setRestrictionValue = createAction(SET_RESTRICTION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_RESTRICTIONS]: createFetchHandler(section, '/restriction'), + + [SAVE_RESTRICTION]: createSaveProviderHandler(section, '/restriction'), + + [DELETE_RESTRICTION]: createRemoveItemHandler(section, '/restriction') + }, + + // + // Reducers + + reducers: { + [SET_RESTRICTION_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js new file mode 100644 index 000000000..97d7223fd --- /dev/null +++ b/frontend/src/Store/Actions/Settings/ui.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.ui'; + +// +// Actions Types + +export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings'; +export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE'; +export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS'; + +// +// Action Creators + +export const fetchUISettings = createThunk(FETCH_UI_SETTINGS); +export const saveUISettings = createThunk(SAVE_UI_SETTINGS); +export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'), + [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui') + }, + + // + // Reducers + + reducers: { + [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js new file mode 100644 index 000000000..a0c784a7d --- /dev/null +++ b/frontend/src/Store/Actions/actionTypes.js @@ -0,0 +1,21 @@ +// +// App + +export const SHOW_MESSAGE = 'SHOW_MESSAGE'; +export const HIDE_MESSAGE = 'HIDE_MESSAGE'; +export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS'; +export const SET_VERSION = 'SET_VERSION'; +export const SET_APP_VALUE = 'SET_APP_VALUE'; +export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE'; + +// +// Settings + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; + +// +// Languages + +export const FETCH_LANGUAGES = 'FETCH_LANGUAGES'; diff --git a/frontend/src/Store/Actions/addMovieActions.js b/frontend/src/Store/Actions/addMovieActions.js new file mode 100644 index 000000000..6232c31aa --- /dev/null +++ b/frontend/src/Store/Actions/addMovieActions.js @@ -0,0 +1,177 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getNewMovie from 'Utilities/Movie/getNewMovie'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'addMovie'; +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isAdding: false, + isAdded: false, + addError: null, + items: [], + + defaults: { + rootFolderPath: '', + monitor: 'true', + qualityProfileId: 0, + tags: [] + } +}; + +export const persistState = [ + 'addMovie.defaults' +]; + +// +// Actions Types + +export const LOOKUP_MOVIE = 'addMovie/lookupMovie'; +export const ADD_MOVIE = 'addMovie/addMovie'; +export const SET_ADD_MOVIE_VALUE = 'addMovie/setAddMovieValue'; +export const CLEAR_ADD_MOVIE = 'addMovie/clearAddMovie'; +export const SET_ADD_MOVIE_DEFAULT = 'addMovie/setAddMovieDefault'; + +// +// Action Creators + +export const lookupMovie = createThunk(LOOKUP_MOVIE); +export const addMovie = createThunk(ADD_MOVIE); +export const clearAddMovie = createAction(CLEAR_ADD_MOVIE); +export const setAddMovieDefault = createAction(SET_ADD_MOVIE_DEFAULT); + +export const setAddMovieValue = createAction(SET_ADD_MOVIE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [LOOKUP_MOVIE]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + if (abortCurrentRequest) { + abortCurrentRequest(); + } + + const { request, abortRequest } = createAjaxRequest({ + url: '/movie/lookup', + data: { + term: payload.term + } + }); + + abortCurrentRequest = abortRequest; + + request.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + }, + + [ADD_MOVIE]: function(getState, payload, dispatch) { + dispatch(set({ section, isAdding: true })); + + const tmdbId = payload.tmdbId; + const items = getState().addMovie.items; + const newSeries = getNewMovie(_.cloneDeep(_.find(items, { tmdbId })), payload); + + const promise = $.ajax({ + url: '/movie', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(newSeries) + }); + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ section: 'movies', ...data }), + + set({ + section, + isAdding: false, + isAdded: true, + addError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isAdding: false, + isAdded: false, + addError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ADD_MOVIE_VALUE]: createSetSettingValueReducer(section), + + [SET_ADD_MOVIE_DEFAULT]: function(state, { payload }) { + const newState = getSectionState(state, section); + + newState.defaults = { + ...newState.defaults, + ...payload + }; + + return updateSectionState(state, section, newState); + }, + + [CLEAR_ADD_MOVIE]: function(state) { + const { + defaults, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js new file mode 100644 index 000000000..66387468c --- /dev/null +++ b/frontend/src/Store/Actions/appActions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createHandleActions from './Creators/createHandleActions'; + +function getDimensions(width, height) { + const dimensions = { + width, + height, + isExtraSmallScreen: width <= 480, + isSmallScreen: width <= 768, + isMediumScreen: width <= 992, + isLargeScreen: width <= 1200 + }; + + return dimensions; +} + +// +// Variables + +export const section = 'app'; +const messagesSection = 'app.messages'; + +// +// State + +export const defaultState = { + dimensions: getDimensions(window.innerWidth, window.innerHeight), + messages: { + items: [] + }, + version: window.Radarr.version, + isUpdated: false, + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false, + isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen +}; + +// +// Action Types + +export const SHOW_MESSAGE = 'app/showMessage'; +export const HIDE_MESSAGE = 'app/hideMessage'; +export const SAVE_DIMENSIONS = 'app/saveDimensions'; +export const SET_VERSION = 'app/setVersion'; +export const SET_APP_VALUE = 'app/setAppValue'; +export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible'; + +// +// Action Creators + +export const saveDimensions = createAction(SAVE_DIMENSIONS); +export const setVersion = createAction(SET_VERSION); +export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE); +export const setAppValue = createAction(SET_APP_VALUE); +export const showMessage = createAction(SHOW_MESSAGE); +export const hideMessage = createAction(HIDE_MESSAGE); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SAVE_DIMENSIONS]: function(state, { payload }) { + const { + width, + height + } = payload; + + const dimensions = getDimensions(width, height); + + return Object.assign({}, state, { dimensions }); + }, + + [SHOW_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, messagesSection, newState); + }, + + [HIDE_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, messagesSection, newState); + }, + + [SET_APP_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [SET_VERSION]: function(state, { payload }) { + const version = payload.version; + + const newState = { + version + }; + + if (state.version !== version) { + newState.isUpdated = true; + } + + return Object.assign({}, state, newState); + }, + + [SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) { + const newState = { + isSidebarVisible: payload.isSidebarVisible + }; + + return Object.assign({}, state, newState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js new file mode 100644 index 000000000..37be3e0d2 --- /dev/null +++ b/frontend/src/Store/Actions/baseActions.js @@ -0,0 +1,29 @@ +import { createAction } from 'redux-actions'; + +// +// Action Types + +export const SET = 'base/set'; + +export const UPDATE = 'base/update'; +export const UPDATE_ITEM = 'base/updateItem'; +export const UPDATE_SERVER_SIDE_COLLECTION = 'base/updateServerSideCollection'; + +export const SET_SETTING_VALUE = 'base/setSettingValue'; +export const CLEAR_PENDING_CHANGES = 'base/clearPendingChanges'; + +export const REMOVE_ITEM = 'base/removeItem'; + +// +// Action Creators + +export const set = createAction(SET); + +export const update = createAction(UPDATE); +export const updateItem = createAction(UPDATE_ITEM); +export const updateServerSideCollection = createAction(UPDATE_SERVER_SIDE_COLLECTION); + +export const setSettingValue = createAction(SET_SETTING_VALUE); +export const clearPendingChanges = createAction(CLEAR_PENDING_CHANGES); + +export const removeItem = createAction(REMOVE_ITEM); diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js new file mode 100644 index 000000000..a6eb4be86 --- /dev/null +++ b/frontend/src/Store/Actions/blacklistActions.js @@ -0,0 +1,139 @@ +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; + +// +// Variables + +export const section = 'blacklist'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'movie.sortTitle', + label: 'Movie Title', + isSortable: true, + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isSortable: true, + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'blacklist.pageSize', + 'blacklist.sortKey', + 'blacklist.sortDirection', + 'blacklist.columns' +]; + +// +// Action Types + +export const FETCH_BLACKLIST = 'blacklist/fetchBlacklist'; +export const GOTO_FIRST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistFirstPage'; +export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPreviousPage'; +export const GOTO_NEXT_BLACKLIST_PAGE = 'blacklist/gotoBlacklistNextPage'; +export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage'; +export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage'; +export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort'; +export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption'; +export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist'; +export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist'; + +// +// Action Creators + +export const fetchBlacklist = createThunk(FETCH_BLACKLIST); +export const gotoBlacklistFirstPage = createThunk(GOTO_FIRST_BLACKLIST_PAGE); +export const gotoBlacklistPreviousPage = createThunk(GOTO_PREVIOUS_BLACKLIST_PAGE); +export const gotoBlacklistNextPage = createThunk(GOTO_NEXT_BLACKLIST_PAGE); +export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE); +export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE); +export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT); +export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION); +export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST); +export const clearBlacklist = createAction(CLEAR_BLACKLIST); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/blacklist', + fetchBlacklist, + { + [serverSideCollectionHandlers.FETCH]: FETCH_BLACKLIST, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_BLACKLIST_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLACKLIST_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLACKLIST_PAGE, + [serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT + }), + + [REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_BLACKLIST]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js new file mode 100644 index 000000000..4ac3d19ed --- /dev/null +++ b/frontend/src/Store/Actions/calendarActions.js @@ -0,0 +1,389 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import moment from 'moment'; +import { filterTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import * as calendarViews from 'Calendar/calendarViews'; +import * as commandNames from 'Commands/commandNames'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; +import { executeCommandHelper } from './commandActions'; + +// +// Variables + +export const section = 'calendar'; + +const viewRanges = { + [calendarViews.MONTH]: 'month', + [calendarViews.FORECAST]: 'day' +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + start: null, + end: null, + dates: [], + dayCount: 7, + view: window.innerWidth > 768 ? 'month' : 'day', + error: null, + items: [], + searchMissingCommandId: null, + + options: { + collapseMultipleEpisodes: false, + showEpisodeInformation: true, + showFinaleIcon: false, + showSpecialIcon: false, + showCutoffUnmetIcon: false + }, + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'all', + label: 'All', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + } + + ] +}; + +export const persistState = [ + 'calendar.view', + 'calendar.selectedFilterKey', + 'calendar.options' +]; + +// +// Actions Types + +export const FETCH_CALENDAR = 'calendar/fetchCalendar'; +export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount'; +export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter'; +export const SET_CALENDAR_VIEW = 'calendar/setCalendarView'; +export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday'; +export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange'; +export const CLEAR_CALENDAR = 'calendar/clearCalendar'; +export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption'; +export const SEARCH_MISSING = 'calendar/searchMissing'; +export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; + +// +// Helpers + +function getDays(start, end) { + const startTime = moment(start); + const endTime = moment(end); + const difference = endTime.diff(startTime, 'days'); + + // Difference is one less than the number of days we need to account for. + return _.times(difference + 1, (i) => { + return startTime.clone().add(i, 'days').toISOString(); + }); +} + +function getDates(time, view, firstDayOfWeek, dayCount) { + const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek'; + + let start = time.clone().startOf('day'); + let end = time.clone().endOf('day'); + + if (view === calendarViews.WEEK) { + start = time.clone().startOf(weekName); + end = time.clone().endOf(weekName); + } + + if (view === calendarViews.FORECAST) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(dayCount - 2, 'days').endOf('day'); + } + + if (view === calendarViews.MONTH) { + start = time.clone().startOf('month').startOf(weekName); + end = time.clone().endOf('month').endOf(weekName); + } + + if (view === calendarViews.AGENDA) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(1, 'month').endOf('day'); + } + + return { + start: start.toISOString(), + end: end.toISOString(), + time: time.toISOString(), + dates: getDays(start, end) + }; +} + +function getPopulatableRange(startDate, endDate, view) { + switch (view) { + case calendarViews.DAY: + return { + start: moment(startDate).subtract(1, 'day').toISOString(), + end: moment(endDate).add(1, 'day').toISOString() + }; + case calendarViews.WEEK: + case calendarViews.FORECAST: + return { + start: moment(startDate).subtract(1, 'week').toISOString(), + end: moment(endDate).add(1, 'week').toISOString() + }; + default: + return { + start: startDate, + end: endDate + }; + } +} + +function isRangePopulated(start, end, state) { + const { + start: currentStart, + end: currentEnd, + view: currentView + } = state; + + if (!currentStart || !currentEnd) { + return false; + } + + const { + start: currentPopulatedStart, + end: currentPopulatedEnd + } = getPopulatableRange(currentStart, currentEnd, currentView); + + if ( + moment(start).isAfter(currentPopulatedStart) && + moment(start).isBefore(currentPopulatedEnd) + ) { + return true; + } + + return false; +} + +// +// Action Creators + +export const fetchCalendar = createThunk(FETCH_CALENDAR); +export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT); +export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER); +export const setCalendarView = createThunk(SET_CALENDAR_VIEW); +export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY); +export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE); +export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE); +export const clearCalendar = createAction(CLEAR_CALENDAR); +export const setCalendarOption = createAction(SET_CALENDAR_OPTION); +export const searchMissing = createThunk(SEARCH_MISSING); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CALENDAR]: function(getState, payload, dispatch) { + const state = getState(); + const calendar = state.calendar; + const unmonitored = calendar.selectedFilterKey === 'all'; + + const { + time = calendar.time, + view = calendar.view + } = payload; + + const dayCount = state.calendar.dayCount; + const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount); + const { start, end } = getPopulatableRange(dates.start, dates.end, view); + const isPrePopulated = isRangePopulated(start, end, state.calendar); + + const basesAttrs = { + section, + isFetching: true + }; + + const attrs = isPrePopulated ? + { + view, + ...basesAttrs, + ...dates + } : + basesAttrs; + + dispatch(set(attrs)); + + const promise = $.ajax({ + url: '/calendar', + data: { + unmonitored, + start, + end + } + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + view, + ...dates, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) { + if (payload.dayCount === getState().calendar.dayCount) { + return; + } + + dispatch(set({ + section, + dayCount: payload.dayCount + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) { + dispatch(set({ + section, + selectedFilterKey: payload.selectedFilterKey + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) { + const state = getState(); + const view = payload.view; + const time = view === calendarViews.FORECAST || calendarViews.AGENDA ? + moment() : + state.calendar.time; + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) { + const state = getState(); + const view = state.calendar.view; + const time = moment(); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).subtract(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).add(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [SEARCH_MISSING]: function(getState, payload, dispatch) { + const { episodeIds } = payload; + + const commandPayload = { + name: commandNames.MOVIE_SEARCH, + episodeIds + }; + + executeCommandHelper(commandPayload, dispatch).then((data) => { + dispatch(set({ + section, + searchMissingCommandId: data.id + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_CALENDAR]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }), + + [SET_CALENDAR_OPTION]: function(state, { payload }) { + const options = state.options; + + return { + ...state, + options: { + ...options, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js new file mode 100644 index 000000000..d506566f7 --- /dev/null +++ b/frontend/src/Store/Actions/captchaActions.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'captcha'; + +// +// State + +export const defaultState = { + refreshing: false, + token: null, + siteKey: null, + secretToken: null, + ray: null, + stoken: null, + responseUrl: null +}; + +// +// Actions Types + +export const REFRESH_CAPTCHA = 'captcha/refreshCaptcha'; +export const GET_CAPTCHA_COOKIE = 'captcha/getCaptchaCookie'; +export const SET_CAPTCHA_VALUE = 'captcha/setCaptchaValue'; +export const RESET_CAPTCHA = 'captcha/resetCaptcha'; + +// +// Action Creators + +export const refreshCaptcha = createThunk(REFRESH_CAPTCHA); +export const getCaptchaCookie = createThunk(GET_CAPTCHA_COOKIE); +export const setCaptchaValue = createAction(SET_CAPTCHA_VALUE); +export const resetCaptcha = createAction(RESET_CAPTCHA); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [REFRESH_CAPTCHA]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'checkCaptcha', + ...payload + }; + + dispatch(setCaptchaValue({ + refreshing: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + if (!data.captchaRequest) { + dispatch(setCaptchaValue({ + refreshing: false + })); + } + + dispatch(setCaptchaValue({ + refreshing: false, + ...data.captchaRequest + })); + }); + + promise.fail(() => { + dispatch(setCaptchaValue({ + refreshing: false + })); + }); + }, + + [GET_CAPTCHA_COOKIE]: function(getState, payload, dispatch) { + const state = getState().captcha; + + const queryParams = { + responseUrl: state.responseUrl, + ray: state.ray, + captchaResponse: payload.captchaResponse + }; + + const actionPayload = { + action: 'getCaptchaCookie', + queryParams, + ...payload + }; + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(setCaptchaValue({ + token: data.captchaToken + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_CAPTCHA_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_CAPTCHA]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState); diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js new file mode 100644 index 000000000..5a7baf58a --- /dev/null +++ b/frontend/src/Store/Actions/commandActions.js @@ -0,0 +1,215 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { isSameCommand } from 'Utilities/Command'; +import { messageTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { showMessage, hideMessage } from './appActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'commands'; + +let lastCommand = null; +let lastCommandTimeout = null; +const removeCommandTimeoutIds = {}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + handlers: {} +}; + +// +// Actions Types + +export const FETCH_COMMANDS = 'commands/fetchCommands'; +export const EXECUTE_COMMAND = 'commands/executeCommand'; +export const CANCEL_COMMAND = 'commands/cancelCommand'; +export const ADD_COMMAND = 'commands/updateCommand'; +export const UPDATE_COMMAND = 'commands/finishCommand'; +export const FINISH_COMMAND = 'commands/addCommand'; +export const REMOVE_COMMAND = 'commands/removeCommand'; + +// +// Action Creators + +export const fetchCommands = createThunk(FETCH_COMMANDS); +export const executeCommand = createThunk(EXECUTE_COMMAND); +export const cancelCommand = createThunk(CANCEL_COMMAND); +export const updateCommand = createThunk(UPDATE_COMMAND); +export const finishCommand = createThunk(FINISH_COMMAND); +export const addCommand = createAction(ADD_COMMAND); +export const removeCommand = createAction(REMOVE_COMMAND); + +// +// Helpers + +function showCommandMessage(payload, dispatch) { + const { + id, + name, + trigger, + message, + body = {}, + status + } = payload; + + const { + sendUpdatesToClient, + suppressMessages + } = body; + + if (!message || !body || !sendUpdatesToClient || suppressMessages) { + return; + } + + let type = messageTypes.INFO; + let hideAfter = 0; + + if (status === 'completed') { + type = messageTypes.SUCCESS; + hideAfter = 4; + } else if (status === 'failed') { + type = messageTypes.ERROR; + hideAfter = trigger === 'manual' ? 10 : 4; + } + + dispatch(showMessage({ + id, + name, + message, + type, + hideAfter + })); +} + +function scheduleRemoveCommand(command, dispatch) { + const { + id, + status + } = command; + + if (status === 'queued') { + return; + } + + const timeoutId = removeCommandTimeoutIds[id]; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + removeCommandTimeoutIds[id] = setTimeout(() => { + dispatch(batchActions([ + removeCommand({ section: 'commands', id }), + hideMessage({ id }) + ])); + + delete removeCommandTimeoutIds[id]; + }, 60000 * 5); +} + +export function executeCommandHelper( payload, dispatch) { + // TODO: show a message for the user + if (lastCommand && isSameCommand(lastCommand, payload)) { + console.warn('Please wait at least 5 seconds before running this command again'); + } + + lastCommand = payload; + + // clear last command after 5 seconds. + if (lastCommandTimeout) { + clearTimeout(lastCommandTimeout); + } + + lastCommandTimeout = setTimeout(() => { + lastCommand = null; + }, 5000); + + const promise = $.ajax({ + url: '/command', + method: 'POST', + data: JSON.stringify(payload) + }); + + return promise.then((data) => { + dispatch(addCommand(data)); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_COMMANDS]: createFetchHandler('commands', '/command'), + + [EXECUTE_COMMAND]: function(getState, payload, dispatch) { + executeCommandHelper(payload, dispatch); + }, + + [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'), + + [UPDATE_COMMAND]: function(getState, payload, dispatch) { + dispatch(updateItem({ section: 'commands', ...payload })); + + showCommandMessage(payload, dispatch); + scheduleRemoveCommand(payload, dispatch); + }, + + [FINISH_COMMAND]: function(getState, payload, dispatch) { + const state = getState(); + const handlers = state.commands.handlers; + + Object.keys(handlers).forEach((key) => { + const handler = handlers[key]; + + if (handler.name === payload.name) { + dispatch(handler.handler(payload)); + } + }); + + dispatch(updateItem({ section: 'commands', ...payload })); + scheduleRemoveCommand(payload, dispatch); + showCommandMessage(payload, dispatch); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [ADD_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items, payload]; + + return newState; + }, + + [REMOVE_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items]; + + const index = _.findIndex(newState.items, { id: payload.id }); + + if (index > -1) { + newState.items.splice(index, 1); + } + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js new file mode 100644 index 000000000..750c3ef6f --- /dev/null +++ b/frontend/src/Store/Actions/customFilterActions.js @@ -0,0 +1,55 @@ +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'customFilters'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + items: [], + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters'; +export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter'; +export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter'; + +// +// Action Creators + +export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS); +export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER); +export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'), + + [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'), + + [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/deviceActions.js b/frontend/src/Store/Actions/deviceActions.js new file mode 100644 index 000000000..089d49bf3 --- /dev/null +++ b/frontend/src/Store/Actions/deviceActions.js @@ -0,0 +1,83 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'devices'; + +// +// State + +export const defaultState = { + items: [], + isFetching: false, + isPopulated: false, + error: false +}; + +// +// Actions Types + +export const FETCH_DEVICES = 'devices/fetchDevices'; +export const CLEAR_DEVICES = 'devices/clearDevices'; + +// +// Action Creators + +export const fetchDevices = createThunk(FETCH_DEVICES); +export const clearDevices = createAction(CLEAR_DEVICES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_DEVICES]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'getDevices', + ...payload + }; + + dispatch(set({ + section, + isFetching: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null, + items: data.devices || [] + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_DEVICES]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js new file mode 100644 index 000000000..81b229b61 --- /dev/null +++ b/frontend/src/Store/Actions/historyActions.js @@ -0,0 +1,257 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'history'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'eventType', + columnLabel: 'Event Type', + isVisible: true, + isModifiable: false + }, + { + name: 'movie.sortTitle', + label: 'Movie', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'downloadClient', + label: 'Download Client', + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isVisible: false + }, + { + name: 'releaseGroup', + label: 'Release Group', + isVisible: false + }, + { + name: 'details', + columnLabel: 'Details', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'grabbed', + label: 'Grabbed', + filters: [ + { + key: 'eventType', + value: '1', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'imported', + label: 'Imported', + filters: [ + { + key: 'eventType', + value: '3', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'failed', + label: 'Failed', + filters: [ + { + key: 'eventType', + value: '4', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'deleted', + label: 'Deleted', + filters: [ + { + key: 'eventType', + value: '5', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'renamed', + label: 'Renamed', + filters: [ + { + key: 'eventType', + value: '6', + type: filterTypes.EQUAL + } + ] + } + ] + +}; + +export const persistState = [ + 'history.pageSize', + 'history.sortKey', + 'history.sortDirection', + 'history.selectedFilterKey' +]; + +// +// Actions Types + +export const FETCH_HISTORY = 'history/fetchHistory'; +export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage'; +export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage'; +export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage'; +export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage'; +export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage'; +export const SET_HISTORY_SORT = 'history/setHistorySort'; +export const SET_HISTORY_FILTER = 'history/setHistoryFilter'; +export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption'; +export const CLEAR_HISTORY = 'history/clearHistory'; +export const MARK_AS_FAILED = 'history/markAsFailed'; + +// +// Action Creators + +export const fetchHistory = createThunk(FETCH_HISTORY); +export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE); +export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE); +export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE); +export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE); +export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE); +export const setHistorySort = createThunk(SET_HISTORY_SORT); +export const setHistoryFilter = createThunk(SET_HISTORY_FILTER); +export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION); +export const clearHistory = createAction(CLEAR_HISTORY); +export const markAsFailed = createThunk(MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/history', + fetchHistory, + { + [serverSideCollectionHandlers.FETCH]: FETCH_HISTORY, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE, + [serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT, + [serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER + }), + + [MARK_AS_FAILED]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: true + })); + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id + } + }); + + promise.done(() => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: null + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_HISTORY]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/importMovieActions.js b/frontend/src/Store/Actions/importMovieActions.js new file mode 100644 index 000000000..e43f1e6b7 --- /dev/null +++ b/frontend/src/Store/Actions/importMovieActions.js @@ -0,0 +1,287 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import getNewMovie from 'Utilities/Movie/getNewMovie'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, removeItem, updateItem } from './baseActions'; +import { fetchRootFolders } from './rootFolderActions'; + +// +// Variables + +export const section = 'importMovie'; +let concurrentLookups = 0; +let abortCurrentLookup = null; +const queue = []; + +// +// State + +export const defaultState = { + isLookingUpMovie: false, + isImporting: false, + isImported: false, + importError: null, + items: [] +}; + +// +// Actions Types + +export const QUEUE_LOOKUP_MOVIE = 'importMovie/queueLookupMovie'; +export const START_LOOKUP_MOVIE = 'importMovie/startLookupMovie'; +export const CANCEL_LOOKUP_MOVIE = 'importMovie/cancelLookupMovie'; +export const CLEAR_IMPORT_MOVIE = 'importMovie/clearImportMovie'; +export const SET_IMPORT_MOVIE_VALUE = 'importMovie/setImportMovieValue'; +export const IMPORT_MOVIE = 'importMovie/importMovie'; + +// +// Action Creators + +export const queueLookupMovie = createThunk(QUEUE_LOOKUP_MOVIE); +export const startLookupMovie = createThunk(START_LOOKUP_MOVIE); +export const importMovie = createThunk(IMPORT_MOVIE); +export const clearImportMovie = createAction(CLEAR_IMPORT_MOVIE); +export const cancelLookupMovie = createAction(CANCEL_LOOKUP_MOVIE); + +export const setImportMovieValue = createAction(SET_IMPORT_MOVIE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [QUEUE_LOOKUP_MOVIE]: function(getState, payload, dispatch) { + const { + name, + path, + term, + topOfQueue = false + } = payload; + + const state = getState().importMovie; + const item = _.find(state.items, { id: name }) || { + id: name, + term, + path, + isFetching: false, + isPopulated: false, + error: null + }; + + dispatch(updateItem({ + section, + ...item, + term, + queued: true, + items: [] + })); + + const itemIndex = queue.indexOf(item.id); + + if (itemIndex >= 0) { + queue.splice(itemIndex, 1); + } + + if (topOfQueue) { + queue.unshift(item.id); + } else { + queue.push(item.id); + } + + if (term && term.length > 2) { + dispatch(startLookupMovie({ start: true })); + } + }, + + [START_LOOKUP_MOVIE]: function(getState, payload, dispatch) { + if (concurrentLookups >= 1) { + return; + } + + const state = getState().importMovie; + + const { + isLookingUpMovie, + items + } = state; + + const queueId = queue[0]; + + if (payload.start && !isLookingUpMovie) { + dispatch(set({ section, isLookingUpMovie: true })); + } else if (!isLookingUpMovie) { + return; + } else if (!queueId) { + dispatch(set({ section, isLookingUpMovie: false })); + return; + } + + concurrentLookups++; + queue.splice(0, 1); + + const queued = items.find((i) => i.id === queueId); + + dispatch(updateItem({ + section, + id: queued.id, + isFetching: true + })); + + const { request, abortRequest } = createAjaxRequest({ + url: '/movie/lookup', + data: { + term: queued.term + } + }); + + abortCurrentLookup = abortRequest; + + request.done((data) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: true, + error: null, + items: data, + queued: false, + selectedMovie: queued.selectedMovie || data[0], + updateOnly: true + })); + }); + + request.fail((xhr) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: false, + error: xhr, + queued: false, + updateOnly: true + })); + }); + + request.always(() => { + concurrentLookups--; + + dispatch(startLookupMovie()); + }); + }, + + [IMPORT_MOVIE]: function(getState, payload, dispatch) { + dispatch(set({ section, isImporting: true })); + + const ids = payload.ids; + const items = getState().importMovie.items; + const addedIds = []; + + const allNewMovies = ids.reduce((acc, id) => { + const item = _.find(items, { id }); + const selectedMovie = item.selectedMovie; + + // Make sure we have a selected series and + // the same series hasn't been added yet. + if (selectedMovie && !_.some(acc, { tmdbId: selectedMovie.tmdbId })) { + const newSeries = getNewMovie(_.cloneDeep(selectedMovie), item); + newSeries.path = item.path; + + addedIds.push(id); + acc.push(newSeries); + } + + return acc; + }, []); + + const promise = $.ajax({ + url: '/movie/import', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(allNewMovies) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isImporting: false, + isImported: true + }), + + ...data.map((series) => updateItem({ section: 'movies', ...series })), + + ...addedIds.map((id) => removeItem({ section, id })) + ])); + + dispatch(fetchRootFolders()); + }); + + promise.fail((xhr) => { + dispatch(batchActions( + set({ + section, + isImporting: false, + isImported: true + }), + + addedIds.map((id) => updateItem({ + section, + id, + importError: xhr + })) + )); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CANCEL_LOOKUP_MOVIE]: function(state) { + return Object.assign({}, state, { isLookingUpMovie: false }); + }, + + [CLEAR_IMPORT_MOVIE]: function(state) { + if (abortCurrentLookup) { + abortCurrentLookup(); + + abortCurrentLookup = null; + } + + queue.splice(0, queue.length); + + return Object.assign({}, state, defaultState); + }, + + [SET_IMPORT_MOVIE_VALUE]: function(state, { payload }) { + const newState = getSectionState(state, section); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, section, newState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js new file mode 100644 index 000000000..d27f9c5b9 --- /dev/null +++ b/frontend/src/Store/Actions/index.js @@ -0,0 +1,53 @@ +import * as addMovie from './addMovieActions'; +import * as app from './appActions'; +import * as blacklist from './blacklistActions'; +import * as calendar from './calendarActions'; +import * as captcha from './captchaActions'; +import * as customFilters from './customFilterActions'; +import * as devices from './deviceActions'; +import * as commands from './commandActions'; +import * as movieFiles from './movieFileActions'; +import * as history from './historyActions'; +import * as importMovie from './importMovieActions'; +import * as interactiveImportActions from './interactiveImportActions'; +import * as oAuth from './oAuthActions'; +import * as organizePreview from './organizePreviewActions'; +import * as paths from './pathActions'; +import * as queue from './queueActions'; +import * as releases from './releaseActions'; +import * as rootFolders from './rootFolderActions'; +import * as movies from './movieActions'; +import * as movieEditor from './movieEditorActions'; +import * as movieHistory from './movieHistoryActions'; +import * as movieIndex from './movieIndexActions'; +import * as settings from './settingsActions'; +import * as system from './systemActions'; +import * as tags from './tagActions'; + +export default [ + addMovie, + app, + blacklist, + calendar, + captcha, + commands, + customFilters, + devices, + movieFiles, + history, + importMovie, + interactiveImportActions, + oAuth, + organizePreview, + paths, + queue, + releases, + rootFolders, + movies, + movieEditor, + movieHistory, + movieIndex, + settings, + system, + tags +]; diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js new file mode 100644 index 000000000..9dd003d3f --- /dev/null +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -0,0 +1,204 @@ +import $ from 'jquery'; +import moment from 'moment'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'interactiveImport'; + +const episodesSection = `${section}.episodes`; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + sortKey: 'quality', + sortDirection: sortDirections.DESCENDING, + recentFolders: [], + importMode: 'move', + sortPredicates: { + relativePath: function(item, direction) { + const relativePath = item.relativePath; + + return relativePath.toLowerCase(); + }, + + series: function(item, direction) { + const series = item.series; + + return series ? series.sortTitle : ''; + }, + + quality: function(item, direction) { + return item.quality ? item.quality.qualityWeight : 0; + } + }, + + episodes: { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'episodeNumber', + sortDirection: sortDirections.ASCENDING, + items: [] + } +}; + +export const persistState = [ + 'interactiveImport.recentFolders', + 'interactiveImport.importMode' +]; + +// +// Actions Types + +export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems'; +export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort'; +export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem'; +export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport'; +export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder'; +export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder'; +export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode'; + +export const FETCH_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/fetchInteractiveImportEpisodes'; +export const SET_INTERACTIVE_IMPORT_EPISODES_SORT = 'interactiveImport/setInteractiveImportEpisodesSort'; +export const CLEAR_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/clearInteractiveImportEpisodes'; + +// +// Action Creators + +export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS); +export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT); +export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM); +export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT); +export const addRecentFolder = createAction(ADD_RECENT_FOLDER); +export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER); +export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE); + +export const fetchInteractiveImportEpisodes = createThunk(FETCH_INTERACTIVE_IMPORT_EPISODES); +export const setInteractiveImportEpisodesSort = createAction(SET_INTERACTIVE_IMPORT_EPISODES_SORT); +export const clearInteractiveImportEpisodes = createAction(CLEAR_INTERACTIVE_IMPORT_EPISODES); + +// +// Action Handlers +export const actionHandlers = handleThunks({ + [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) { + if (!payload.downloadId && !payload.folder) { + dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } })); + return; + } + + dispatch(set({ section, isFetching: true })); + + const promise = $.ajax({ + url: '/manualimport', + data: payload + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [FETCH_INTERACTIVE_IMPORT_EPISODES]: createFetchHandler('interactiveImport.episodes', '/episode') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => { + const id = payload.id; + const newState = Object.assign({}, state); + const items = newState.items; + const index = items.findIndex((item) => item.id === id); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [ADD_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolder = { folder, lastUsed: moment().toISOString() }; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + if (index > -1) { + recentFolders.splice(index, 1, recentFolder); + } else { + recentFolders.push(recentFolder); + } + + return Object.assign({}, state, { recentFolders }); + }, + + [REMOVE_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + recentFolders.splice(index, 1); + + return Object.assign({}, state, { recentFolders }); + }, + + [CLEAR_INTERACTIVE_IMPORT]: function(state) { + const newState = { + ...defaultState, + recentFolders: state.recentFolders, + importMode: state.importMode + }; + + return newState; + }, + + [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section), + + [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) { + return Object.assign({}, state, { importMode: payload.importMode }); + }, + + [SET_INTERACTIVE_IMPORT_EPISODES_SORT]: createSetClientSideCollectionSortReducer(episodesSection), + + [CLEAR_INTERACTIVE_IMPORT_EPISODES]: (state) => { + return updateSectionState(state, episodesSection, { + ...defaultState.episodes + }); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/movieActions.js b/frontend/src/Store/Actions/movieActions.js new file mode 100644 index 000000000..1015867ad --- /dev/null +++ b/frontend/src/Store/Actions/movieActions.js @@ -0,0 +1,255 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +// import { batchActions } from 'redux-batched-actions'; +import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'movies'; + +export const filters = [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored Only', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'wanted', + label: 'Wanted Missing', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + }, + { + key: 'hasFile', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'cutoffunmet', + label: 'Cut-off Unmet', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + }, + { + key: 'hasFile', + value: true, + type: filterTypes.EQUAL + }, + { + key: 'movieFile.qualityCutoffNotMet', + value: true, + type: filterTypes.EQUAL + } + ] + } +]; + +export const filterPredicates = { + missing: function(item) { + const { statistics = {} } = item; + + return statistics.episodeCount - statistics.episodeFileCount > 0; + }, + + added: function(item, filterValue, type) { + return dateFilterPredicate(item.added, filterValue, type); + }, + + ratings: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + return predicate(item.ratings.value * 10, filterValue); + } +}; + +export const sortPredicates = { + status: function(item) { + let result = 0; + + if (item.monitored) { + result += 2; + } + + if (item.status === 'continuing') { + result++; + } + + return result; + } +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_MOVIES = 'movies/fetchMovies'; +export const SET_MOVIE_VALUE = 'movies/setMovieValue'; +export const SAVE_MOVIE = 'movies/saveMovie'; +export const DELETE_MOVIE = 'movies/deleteMovie'; + +export const TOGGLE_MOVIE_MONITORED = 'movies/toggleMovieMonitored'; + +// +// Action Creators + +export const fetchMovies = createThunk(FETCH_MOVIES); +export const saveMovie = createThunk(SAVE_MOVIE, (payload) => { + const newPayload = { + ...payload + }; + + if (payload.moveFiles) { + newPayload.queryParams = { + moveFiles: true + }; + } + + delete newPayload.moveFiles; + + return newPayload; +}); + +export const deleteMovie = createThunk(DELETE_MOVIE, (payload) => { + return { + ...payload, + queryParams: { + deleteFiles: payload.deleteFiles + } + }; +}); + +export const toggleMovieMonitored = createThunk(TOGGLE_MOVIE_MONITORED); + +export const setMovieValue = createAction(SET_MOVIE_VALUE, (payload) => { + return { + section: 'movies', + ...payload + }; +}); + +// +// Helpers + +function getSaveAjaxOptions({ ajaxOptions, payload }) { + if (payload.moveFolder) { + ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`; + } + + return ajaxOptions; +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_MOVIES]: createFetchHandler(section, '/movie'), + [SAVE_MOVIE]: createSaveProviderHandler(section, '/movie', { getAjaxOptions: getSaveAjaxOptions }), + [DELETE_MOVIE]: createRemoveItemHandler(section, '/movie'), + + [TOGGLE_MOVIE_MONITORED]: (getState, payload, dispatch) => { + const { + movieId: id, + monitored + } = payload; + + const movie = _.find(getState().movies.items, { id }); + + dispatch(updateItem({ + id, + section, + isSaving: true + })); + + const promise = $.ajax({ + url: `/movie/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...movie, + monitored + }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(updateItem({ + id, + section, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section, + isSaving: false + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_MOVIE_VALUE]: createSetSettingValueReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/movieEditorActions.js b/frontend/src/Store/Actions/movieEditorActions.js new file mode 100644 index 000000000..4c775e838 --- /dev/null +++ b/frontend/src/Store/Actions/movieEditorActions.js @@ -0,0 +1,179 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, updateItem } from './baseActions'; +import { filters, filterPredicates, sortPredicates } from './movieActions'; + +// +// Variables + +export const section = 'movieEditor'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + selectedFilterKey: 'all', + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ], + + sortPredicates +}; + +export const persistState = [ + 'movieEditor.sortKey', + 'movieEditor.sortDirection', + 'movieEditor.selectedFilterKey', + 'movieEditor.customFilters' +]; + +// +// Actions Types + +export const SET_SERIES_EDITOR_SORT = 'movieEditor/setSeriesEditorSort'; +export const SET_SERIES_EDITOR_FILTER = 'movieEditor/setSeriesEditorFilter'; +export const SAVE_SERIES_EDITOR = 'movieEditor/saveSeriesEditor'; +export const BULK_DELETE_SERIES = 'movieEditor/bulkDeleteSeries'; +// +// Action Creators + +export const setSeriesEditorSort = createAction(SET_SERIES_EDITOR_SORT); +export const setSeriesEditorFilter = createAction(SET_SERIES_EDITOR_FILTER); +export const saveSeriesEditor = createThunk(SAVE_SERIES_EDITOR); +export const bulkDeleteSeries = createThunk(BULK_DELETE_SERIES); +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [SAVE_SERIES_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/series/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((series) => { + return updateItem({ + id: series.id, + section: 'series', + ...series + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = $.ajax({ + url: '/series/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }); + + promise.done(() => { + // SignaR will take care of removing the series from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SERIES_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_SERIES_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/movieFileActions.js b/frontend/src/Store/Actions/movieFileActions.js new file mode 100644 index 000000000..a17a33ba8 --- /dev/null +++ b/frontend/src/Store/Actions/movieFileActions.js @@ -0,0 +1,210 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import movieEntities from 'Movie/movieEntities'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, removeItem, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'movieFiles'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSaving: false, + saveError: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_MOVIE_FILES = 'movieFiles/fetchMovieFiles'; +export const DELETE_MOVIE_FILE = 'movieFiles/deleteMovieFile'; +export const DELETE_MOVIE_FILES = 'movieFiles/deleteMovieFiles'; +export const UPDATE_MOVIE_FILES = 'movieFiles/updateMovieFiles'; +export const CLEAR_MOVIE_FILES = 'movieFiles/clearMovieFiles'; + +// +// Action Creators + +export const fetchMovieFiles = createThunk(FETCH_MOVIE_FILES); +export const deleteMovieFile = createThunk(DELETE_MOVIE_FILE); +export const deleteMovieFiles = createThunk(DELETE_MOVIE_FILES); +export const updateMovieFiles = createThunk(UPDATE_MOVIE_FILES); +export const clearMovieFiles = createAction(CLEAR_MOVIE_FILES); + +// +// Helpers + +const deleteMovieFileHelper = createRemoveItemHandler(section, '/movieFile'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_MOVIE_FILES]: createFetchHandler(section, '/movieFile'), + + [DELETE_MOVIE_FILE]: function(getState, payload, dispatch) { + const { + id: movieFileId, + movieEntity = movieEntities.MOVIES + } = payload; + + const movieSection = _.last(movieEntity.split('.')); + const deletePromise = deleteMovieFileHelper(getState, payload, dispatch); + + deletePromise.done(() => { + const movies = getState().movies.items; + const moviesWithRemovedFiles = _.filter(movies, { movieFileId }); + + dispatch(batchActions([ + ...moviesWithRemovedFiles.map((movie) => { + return updateItem({ + section: movieSection, + ...movie, + movieFileId: 0, + hasFile: false + }); + }) + ])); + }); + }, + + [DELETE_MOVIE_FILES]: function(getState, payload, dispatch) { + const { + movieFileIds + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const promise = $.ajax({ + url: '/movieFile/bulk', + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ movieFileIds }) + }); + + promise.done(() => { + const movies = getState().movies.items; + const moviesWithRemovedFiles = movieFileIds.reduce((acc, movieFileId) => { + acc.push(..._.filter(movies, { movieFileId })); + + return acc; + }, []); + + dispatch(batchActions([ + ...movieFileIds.map((id) => { + return removeItem({ section, id }); + }), + + ...moviesWithRemovedFiles.map((movie) => { + return updateItem({ + section: 'movies', + ...movie, + movieFileId: 0, + hasFile: false + }); + }), + + set({ + section, + isDeleting: false, + deleteError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + }, + + [UPDATE_MOVIE_FILES]: function(getState, payload, dispatch) { + const { + movieFileIds, + language, + quality + } = payload; + + dispatch(set({ section, isSaving: true })); + + const data = { + movieFileIds + }; + + if (language) { + data.language = language; + } + + if (quality) { + data.quality = quality; + } + + const promise = $.ajax({ + url: '/movieFile/editor', + method: 'PUT', + dataType: 'json', + data: JSON.stringify(data) + }); + + promise.done(() => { + dispatch(batchActions([ + ...movieFileIds.map((id) => { + const props = {}; + + if (language) { + props.language = language; + } + + if (quality) { + props.quality = quality; + } + + return updateItem({ section, id, ...props }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_MOVIE_FILES]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/movieHistoryActions.js b/frontend/src/Store/Actions/movieHistoryActions.js new file mode 100644 index 000000000..f835b2009 --- /dev/null +++ b/frontend/src/Store/Actions/movieHistoryActions.js @@ -0,0 +1,104 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'movieHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_SERIES_HISTORY = 'seriesHistory/fetchMovieHistory'; +export const CLEAR_SERIES_HISTORY = 'seriesHistory/clearMovieHistory'; +export const SERIES_HISTORY_MARK_AS_FAILED = 'seriesHistory/seriesHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchMovieHistory = createThunk(FETCH_SERIES_HISTORY); +export const clearMovieHistory = createAction(CLEAR_SERIES_HISTORY); +export const seriesHistoryMarkAsFailed = createThunk(SERIES_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_SERIES_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = $.ajax({ + url: '/history/series', + data: payload + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SERIES_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + seriesId, + seasonNumber + } = payload; + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }); + + promise.done(() => { + dispatch(fetchMovieHistory({ seriesId, seasonNumber })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_SERIES_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js new file mode 100644 index 000000000..007070f29 --- /dev/null +++ b/frontend/src/Store/Actions/movieIndexActions.js @@ -0,0 +1,320 @@ +import { createAction } from 'redux-actions'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { filters, filterPredicates, sortPredicates } from './movieActions'; +// +// Variables + +export const section = 'movieIndex'; + +// +// State + +export const defaultState = { + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + view: 'posters', + + posterOptions: { + detailedProgressBar: false, + size: 'large', + showTitle: false, + showMonitored: true, + showQualityProfile: true, + showSearchAction: false + }, + + overviewOptions: { + detailedProgressBar: false, + size: 'medium', + showMonitored: true, + showStudio: true, + showQualityProfile: true, + showAdded: false, + showPath: false, + showSizeOnDisk: false, + showSearchAction: false + }, + + tableOptions: { + showSearchAction: false + }, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'sortTitle', + label: 'Movie Title', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'studio', + label: 'Studio', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'added', + label: 'Added', + isSortable: true, + isVisible: false + }, + { + name: 'inCinemas', + label: 'In Cinemas', + isSortable: true, + isVisible: false + }, + { + name: 'physicalRelease', + label: 'Physical Release', + isSortable: true, + isVisible: false + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: false + }, + { + name: 'genres', + label: 'Genres', + isSortable: false, + isVisible: false + }, + { + name: 'ratings', + label: 'Rating', + isSortable: true, + isVisible: false + }, + { + name: 'certification', + label: 'Certification', + isSortable: false, + isVisible: false + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + sortPredicates: { + ...sortPredicates, + + studio: function(item) { + const studio = item.studio; + + return studio ? studio.toLowerCase() : ''; + }, + + sizeOnDisk: function(item) { + const { statistics = {} } = item; + + return statistics.sizeOnDisk; + }, + + ratings: function(item) { + const { ratings = {} } = item; + + return ratings.value; + } + }, + + selectedFilterKey: 'all', + + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'studio', + label: 'Studio', + type: filterBuilderTypes.STRING + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'added', + label: 'Added', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'inCinemas', + label: 'In Cinemas', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'physicalRelease', + label: 'Physical Release', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES + }, + { + name: 'genres', + label: 'Genres', + type: filterBuilderTypes.ARRAY, + optionsSelector: function(items) { + const tagList = items.reduce((acc, series) => { + series.genres.forEach((genre) => { + acc.push({ + id: genre, + name: genre + }); + }); + + return acc; + }, []); + + return tagList.sort(sortByName); + } + }, + { + name: 'ratings', + label: 'Rating', + type: filterBuilderTypes.NUMBER + }, + { + name: 'certification', + label: 'Certification', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] +}; + +export const persistState = [ + 'movieIndex.sortKey', + 'movieIndex.sortDirection', + 'movieIndex.selectedFilterKey', + 'movieIndex.customFilters', + 'movieIndex.view', + 'movieIndex.columns', + 'movieIndex.posterOptions', + 'movieIndex.overviewOptions', + 'movieIndex.tableOptions' +]; + +// +// Actions Types + +export const SET_MOVIE_SORT = 'movieIndex/setMovieSort'; +export const SET_MOVIE_FILTER = 'movieIndex/setMovieFilter'; +export const SET_MOVIE_VIEW = 'movieIndex/setMovieView'; +export const SET_MOVIE_TABLE_OPTION = 'movieIndex/setMovieTableOption'; +export const SET_MOVIE_POSTER_OPTION = 'movieIndex/setMoviePosterOption'; +export const SET_MOVIE_OVERVIEW_OPTION = 'movieIndex/setMovieOverviewOption'; + +// +// Action Creators + +export const setMovieSort = createAction(SET_MOVIE_SORT); +export const setMovieFilter = createAction(SET_MOVIE_FILTER); +export const setMovieView = createAction(SET_MOVIE_VIEW); +export const setMovieTableOption = createAction(SET_MOVIE_TABLE_OPTION); +export const setMoviePosterOption = createAction(SET_MOVIE_POSTER_OPTION); +export const setMovieOverviewOption = createAction(SET_MOVIE_OVERVIEW_OPTION); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_MOVIE_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_MOVIE_FILTER]: createSetClientSideCollectionFilterReducer(section), + + [SET_MOVIE_VIEW]: function(state, { payload }) { + return Object.assign({}, state, { view: payload.view }); + }, + + [SET_MOVIE_TABLE_OPTION]: createSetTableOptionReducer(section), + + [SET_MOVIE_POSTER_OPTION]: function(state, { payload }) { + const posterOptions = state.posterOptions; + + return { + ...state, + posterOptions: { + ...posterOptions, + ...payload + } + }; + }, + + [SET_MOVIE_OVERVIEW_OPTION]: function(state, { payload }) { + const overviewOptions = state.overviewOptions; + + return { + ...state, + overviewOptions: { + ...overviewOptions, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js new file mode 100644 index 000000000..9ed9b46d6 --- /dev/null +++ b/frontend/src/Store/Actions/oAuthActions.js @@ -0,0 +1,205 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { set } from 'Store/Actions/baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'oAuth'; +const callbackUrl = `${window.location.origin}${window.Radarr.urlBase}/oauth.html`; + +// +// State + +export const defaultState = { + authorizing: false, + result: null, + error: null +}; + +// +// Actions Types + +export const START_OAUTH = 'oAuth/startOAuth'; +export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue'; +export const RESET_OAUTH = 'oAuth/resetOAuth'; + +// +// Action Creators + +export const startOAuth = createThunk(START_OAUTH); +export const setOAuthValue = createAction(SET_OAUTH_VALUE); +export const resetOAuth = createAction(RESET_OAUTH); + +// +// Helpers + +function showOAuthWindow(url, payload) { + const deferred = $.Deferred(); + const selfWindow = window; + + const newWindow = window.open(url); + + if ( + !newWindow || + newWindow.closed || + typeof newWindow.closed == 'undefined' + ) { + + // A fake validation error to mimic a 400 response from the API. + const error = { + status: 400, + responseJSON: [ + { + propertyName: payload.name, + errorMessage: 'Pop-ups are being blocked by your browser' + } + ] + }; + + return deferred.reject(error).promise(); + } + + selfWindow.onCompleteOauth = function(query, onComplete) { + delete selfWindow.onCompleteOauth; + + const queryParams = {}; + const splitQuery = query.substring(1).split('&'); + + splitQuery.forEach((param) => { + if (param) { + const paramSplit = param.split('='); + + queryParams[paramSplit[0]] = paramSplit[1]; + } + }); + + onComplete(); + deferred.resolve(queryParams); + }; + + return deferred.promise(); +} + +function executeIntermediateRequest(payload, ajaxOptions) { + return $.ajax(ajaxOptions).then((data) => { + return requestAction({ + action: 'continueOAuth', + queryParams: { + ...data, + callbackUrl + }, + ...payload + }); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [START_OAUTH]: function(getState, payload, dispatch) { + const { + name, + section: actionSection, + ...otherPayload + } = payload; + + const actionPayload = { + action: 'startOAuth', + queryParams: { callbackUrl }, + ...otherPayload + }; + + dispatch(setOAuthValue({ + authorizing: true + })); + + let startResponse = {}; + + const promise = requestAction(actionPayload) + .then((response) => { + startResponse = response; + + if (response.oauthUrl) { + return showOAuthWindow(response.oauthUrl, payload); + } + + return executeIntermediateRequest(otherPayload, response).then((intermediateResponse) => { + startResponse = intermediateResponse; + + return showOAuthWindow(intermediateResponse.oauthUrl, payload); + }); + }) + .then((queryParams) => { + return requestAction({ + action: 'getOAuthToken', + queryParams: { + ...startResponse, + ...queryParams + }, + ...otherPayload + }); + }) + .then((response) => { + dispatch(setOAuthValue({ + authorizing: false, + result: response, + error: null + })); + }); + + promise.done(() => { + // Clear any previously set save error. + dispatch(set({ + section: actionSection, + saveError: null + })); + }); + + promise.fail((xhr) => { + const actions = [ + setOAuthValue({ + authorizing: false, + result: null, + error: xhr + }) + ]; + + if (xhr.status === 400) { + // Set a save error so the UI can display validation errors to the user. + actions.splice(0, 0, set({ + section: actionSection, + saveError: xhr + })); + } + + dispatch(batchActions(actions)); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_OAUTH_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_OAUTH]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js new file mode 100644 index 000000000..78f943f32 --- /dev/null +++ b/frontend/src/Store/Actions/organizePreviewActions.js @@ -0,0 +1,51 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'organizePreview'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ORGANIZE_PREVIEW = 'organizePreview/fetchOrganizePreview'; +export const CLEAR_ORGANIZE_PREVIEW = 'organizePreview/clearOrganizePreview'; + +// +// Action Creators + +export const fetchOrganizePreview = createThunk(FETCH_ORGANIZE_PREVIEW); +export const clearOrganizePreview = createAction(CLEAR_ORGANIZE_PREVIEW); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename') + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_ORGANIZE_PREVIEW]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js new file mode 100644 index 000000000..129a9cb4d --- /dev/null +++ b/frontend/src/Store/Actions/pathActions.js @@ -0,0 +1,110 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'paths'; + +// +// State + +export const defaultState = { + currentPath: '', + isPopulated: false, + isFetching: false, + error: null, + directories: [], + files: [], + parent: null +}; + +// +// Actions Types + +export const FETCH_PATHS = 'paths/fetchPaths'; +export const UPDATE_PATHS = 'paths/updatePaths'; +export const CLEAR_PATHS = 'paths/clearPaths'; + +// +// Action Creators + +export const fetchPaths = createThunk(FETCH_PATHS); +export const updatePaths = createAction(UPDATE_PATHS); +export const clearPaths = createAction(CLEAR_PATHS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_PATHS]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + path, + allowFoldersWithoutTrailingSlashes = false + } = payload; + + const promise = $.ajax({ + url: '/filesystem', + data: { + path, + allowFoldersWithoutTrailingSlashes + } + }); + + promise.done((data) => { + dispatch(updatePaths({ path, ...data })); + + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.currentPath = payload.path; + newState.directories = payload.directories; + newState.files = payload.files; + newState.parent = payload.parent; + + return newState; + }, + + [CLEAR_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.path = ''; + newState.directories = []; + newState.files = []; + newState.parent = ''; + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js new file mode 100644 index 000000000..2db89a75f --- /dev/null +++ b/frontend/src/Store/Actions/queueActions.js @@ -0,0 +1,439 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'queue'; +const status = `${section}.status`; +const details = `${section}.details`; +const paged = `${section}.paged`; + +// +// State + +export const defaultState = { + options: { + includeUnknownMovieItems: false + }, + + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + params: {} + }, + + paged: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'timeleft', + sortDirection: sortDirections.ASCENDING, + error: null, + items: [], + isGrabbing: false, + isRemoving: false, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'series.sortTitle', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isSortable: true, + isVisible: true + }, + { + name: 'episode.title', + label: 'Episode Title', + isVisible: true + }, + { + name: 'episode.airDateUtc', + label: 'Episode Air Date', + isSortable: true, + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'protocol', + label: 'Protocol', + isSortable: true, + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'downloadClient', + label: 'Download Client', + isSortable: true, + isVisible: false + }, + { + name: 'estimatedCompletionTime', + label: 'Timeleft', + isSortable: true, + isVisible: true + }, + { + name: 'progress', + label: 'Progress', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] + } +}; + +export const persistState = [ + 'queue.options', + 'queue.paged.pageSize', + 'queue.paged.sortKey', + 'queue.paged.sortDirection', + 'queue.paged.columns' +]; + +// +// Helpers + +function fetchDataAugmenter(getState, payload, data) { + data.includeUnknownMovieItems = getState().queue.options.includeUnknownMovieItems; +} + +// +// Actions Types + +export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus'; + +export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails'; +export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails'; + +export const FETCH_QUEUE = 'queue/fetchQueue'; +export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage'; +export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage'; +export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage'; +export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage'; +export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage'; +export const SET_QUEUE_SORT = 'queue/setQueueSort'; +export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption'; +export const SET_QUEUE_OPTION = 'queue/setQueueOption'; +export const CLEAR_QUEUE = 'queue/clearQueue'; + +export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem'; +export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems'; +export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem'; +export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems'; + +// +// Action Creators + +export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS); + +export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS); +export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS); + +export const fetchQueue = createThunk(FETCH_QUEUE); +export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE); +export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE); +export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE); +export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE); +export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE); +export const setQueueSort = createThunk(SET_QUEUE_SORT); +export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION); +export const setQueueOption = createAction(SET_QUEUE_OPTION); +export const clearQueue = createAction(CLEAR_QUEUE); + +export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM); +export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS); +export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM); +export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS); + +// +// Helpers + +const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'), + + [FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) { + let params = payload; + + // If the payload params are empty try to get params from state. + + if (params && !_.isEmpty(params)) { + dispatch(set({ section: details, params })); + } else { + params = getState().queue.details.params; + } + + // Ensure there are params before trying to fetch the queue + // so we don't make a bad request to the server. + + if (params && !_.isEmpty(params)) { + fetchQueueDetailsHelper(getState, params, dispatch); + } + }, + + ...createServerSideCollectionHandlers( + paged, + '/queue', + fetchQueue, + { + [serverSideCollectionHandlers.FETCH]: FETCH_QUEUE, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE, + [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT + }, + fetchDataAugmenter + ), + + [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ section: paged, id, isGrabbing: true })); + + const promise = $.ajax({ + url: `/queue/grab/${id}`, + method: 'POST' + }); + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: xhr + })); + }); + }, + + [GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const ids = payload.ids; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: true + }); + }), + + set({ + section: paged, + isGrabbing: true + }) + ])); + + const promise = $.ajax({ + url: '/queue/grab/bulk', + method: 'POST', + dataType: 'json', + data: JSON.stringify(payload) + }); + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ section: paged, isGrabbing: false }) + ])); + }); + }, + + [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) { + const { + id, + blacklist + } = payload; + + dispatch(updateItem({ section: paged, id, isRemoving: true })); + + const promise = $.ajax({ + url: `/queue/${id}?blacklist=${blacklist}`, + method: 'DELETE' + }); + + promise.done((data) => { + dispatch(fetchQueue()); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ section: paged, id, isRemoving: false })); + }); + }, + + [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const { + ids, + blacklist + } = payload; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: true + }); + }), + + set({ section: paged, isRemoving: true }) + ])); + + const promise = $.ajax({ + url: `/queue/bulk?blacklist=${blacklist}`, + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ ids }) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ section: paged, isRemoving: false }), + fetchQueue() + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: false + }); + }), + + set({ section: paged, isRemoving: false }) + ])); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details), + + [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged), + + [SET_QUEUE_OPTION]: function(state, { payload }) { + const queueOptions = state.options; + + return { + ...state, + options: { + ...queueOptions, + ...payload + } + }; + }, + + [CLEAR_QUEUE]: createClearReducer(paged, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/reducers.js b/frontend/src/Store/Actions/reducers.js new file mode 100644 index 000000000..0254cd226 --- /dev/null +++ b/frontend/src/Store/Actions/reducers.js @@ -0,0 +1,20 @@ +import { combineReducers } from 'redux'; +import { enableBatching } from 'redux-batched-actions'; +import { routerReducer } from 'react-router-redux'; +import actions from 'Store/Actions'; + +const defaultState = {}; + +const reducers = { + routing: routerReducer +}; + +actions.forEach((action) => { + const section = action.section; + + defaultState[section] = action.defaultState; + reducers[section] = action.reducers; +}); + +export { defaultState }; +export default enableBatching(combineReducers(reducers)); diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js new file mode 100644 index 000000000..1ea4fa6b3 --- /dev/null +++ b/frontend/src/Store/Actions/releaseActions.js @@ -0,0 +1,280 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'releases'; +export const episodeSection = 'releases.episode'; +export const seasonSection = 'releases.season'; + +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + sortKey: 'releaseWeight', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + peers: function(item, direction) { + const seeders = item.seeders || 0; + const leechers = item.leechers || 0; + + return seeders * 1000000 + leechers; + }, + + rejections: function(item, direction) { + const rejections = item.rejections; + const releaseWeight = item.releaseWeight; + + if (rejections.length !== 0) { + return releaseWeight + 1000000; + } + + return releaseWeight; + } + }, + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'season-pack', + label: 'Season Pack', + filters: [ + { + key: 'fullSeason', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'not-season-pack', + label: 'Not Season Pack', + filters: [ + { + key: 'fullSeason', + value: false, + type: filterTypes.EQUAL + } + ] + } + ], + + filterPredicates: { + quality: function(item, value, type) { + const qualityId = item.quality.quality.id; + + if (type === filterTypes.EQUAL) { + return qualityId === value; + } + + if (type === filterTypes.NOT_EQUAL) { + return qualityId !== value; + } + + // Default to false + return false; + } + }, + + filterBuilderProps: [ + { + name: 'title', + label: 'Title', + type: filterBuilderTypes.STRING + }, + { + name: 'age', + label: 'Age', + type: filterBuilderTypes.NUMBER + }, + { + name: 'protocol', + label: 'Protocol', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.PROTOCOL + }, + { + name: 'indexerId', + label: 'Indexer', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.INDEXER + }, + { + name: 'size', + label: 'Size', + type: filterBuilderTypes.NUMBER + }, + { + name: 'seeders', + label: 'Seeders', + type: filterBuilderTypes.NUMBER + }, + { + name: 'peers', + label: 'Peers', + type: filterBuilderTypes.NUMBER + }, + { + name: 'quality', + label: 'Quality', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY + }, + { + name: 'rejections', + label: 'Rejections', + type: filterBuilderTypes.NUMBER + } + ], + + episode: { + selectedFilterKey: 'all' + }, + + season: { + selectedFilterKey: 'season-pack' + } +}; + +export const persistState = [ + 'releases.selectedFilterKey', + 'releases.episode.customFilters', + 'releases.season.customFilters' +]; + +// +// Actions Types + +export const FETCH_RELEASES = 'releases/fetchReleases'; +export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases'; +export const SET_RELEASES_SORT = 'releases/setReleasesSort'; +export const CLEAR_RELEASES = 'releases/clearReleases'; +export const GRAB_RELEASE = 'releases/grabRelease'; +export const UPDATE_RELEASE = 'releases/updateRelease'; +export const SET_EPISODE_RELEASES_FILTER = 'releases/setEpisodeReleasesFilter'; +export const SET_SEASON_RELEASES_FILTER = 'releases/setSeasonReleasesFilter'; + +// +// Action Creators + +export const fetchReleases = createThunk(FETCH_RELEASES); +export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES); +export const setReleasesSort = createAction(SET_RELEASES_SORT); +export const clearReleases = createAction(CLEAR_RELEASES); +export const grabRelease = createThunk(GRAB_RELEASE); +export const updateRelease = createAction(UPDATE_RELEASE); +export const setEpisodeReleasesFilter = createAction(SET_EPISODE_RELEASES_FILTER); +export const setSeasonReleasesFilter = createAction(SET_SEASON_RELEASES_FILTER); + +// +// Helpers + +const fetchReleasesHelper = createFetchHandler(section, '/release'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_RELEASES]: function(getState, payload, dispatch) { + const abortRequest = fetchReleasesHelper(getState, payload, dispatch); + + abortCurrentRequest = abortRequest; + }, + + [CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) { + if (abortCurrentRequest) { + abortCurrentRequest = abortCurrentRequest(); + } + }, + + [GRAB_RELEASE]: function(getState, payload, dispatch) { + const guid = payload.guid; + + dispatch(updateRelease({ guid, isGrabbing: true })); + + const promise = $.ajax({ + url: '/release', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(payload) + }); + + promise.done((data) => { + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: true, + grabError: null + })); + }); + + promise.fail((xhr) => { + const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue'; + + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: false, + grabError + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RELEASES]: (state) => { + const { + episode, + season, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + }, + + [UPDATE_RELEASE]: (state, { payload }) => { + const guid = payload.guid; + const newState = Object.assign({}, state); + const items = newState.items; + + // Return early if there aren't any items (the user closed the modal) + if (!items.length) { + return; + } + + const index = items.findIndex((item) => item.guid === guid); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_EPISODE_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(episodeSection), + [SET_SEASON_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(seasonSection) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js new file mode 100644 index 000000000..8180cdc7d --- /dev/null +++ b/frontend/src/Store/Actions/rootFolderActions.js @@ -0,0 +1,97 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'rootFolders'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders'; +export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder'; +export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder'; + +// +// Action Creators + +export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS); +export const addRootFolder = createThunk(ADD_ROOT_FOLDER); +export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'), + + [DELETE_ROOT_FOLDER]: createRemoveItemHandler( + 'rootFolders', + '/rootFolder', + (state) => state.rootFolders + ), + + [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) { + const path = payload.path; + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/rootFolder', + method: 'POST', + data: JSON.stringify({ path }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ + section, + ...data + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js new file mode 100644 index 000000000..0dab4fb54 --- /dev/null +++ b/frontend/src/Store/Actions/settingsActions.js @@ -0,0 +1,139 @@ +import { createAction } from 'redux-actions'; +import { handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import delayProfiles from './Settings/delayProfiles'; +import downloadClients from './Settings/downloadClients'; +import downloadClientOptions from './Settings/downloadClientOptions'; +import general from './Settings/general'; +import indexerOptions from './Settings/indexerOptions'; +import indexers from './Settings/indexers'; +import netImportOptions from './Settings/netImportOptions'; +import netImports from './Settings/netImports'; +import mediaManagement from './Settings/mediaManagement'; +import metadata from './Settings/metadata'; +import naming from './Settings/naming'; +import namingExamples from './Settings/namingExamples'; +import notifications from './Settings/notifications'; +import qualityDefinitions from './Settings/qualityDefinitions'; +import qualityProfiles from './Settings/qualityProfiles'; +import remotePathMappings from './Settings/remotePathMappings'; +import restrictions from './Settings/restrictions'; +import ui from './Settings/ui'; + +export * from './Settings/delayProfiles'; +export * from './Settings/downloadClients'; +export * from './Settings/downloadClientOptions'; +export * from './Settings/general'; +export * from './Settings/indexerOptions'; +export * from './Settings/indexers'; +export * from './Settings/netImportOptions'; +export * from './Settings/netImports'; +export * from './Settings/mediaManagement'; +export * from './Settings/metadata'; +export * from './Settings/naming'; +export * from './Settings/namingExamples'; +export * from './Settings/notifications'; +export * from './Settings/qualityDefinitions'; +export * from './Settings/qualityProfiles'; +export * from './Settings/remotePathMappings'; +export * from './Settings/restrictions'; +export * from './Settings/ui'; + +// +// Variables + +export const section = 'settings'; + +// +// State + +export const defaultState = { + advancedSettings: false, + + delayProfiles: delayProfiles.defaultState, + downloadClients: downloadClients.defaultState, + downloadClientOptions: downloadClientOptions.defaultState, + general: general.defaultState, + indexerOptions: indexerOptions.defaultState, + indexers: indexers.defaultState, + netImportOptions: netImportOptions.defaultState, + netImports: netImports.defaultState, + mediaManagement: mediaManagement.defaultState, + metadata: metadata.defaultState, + naming: naming.defaultState, + namingExamples: namingExamples.defaultState, + notifications: notifications.defaultState, + qualityDefinitions: qualityDefinitions.defaultState, + qualityProfiles: qualityProfiles.defaultState, + remotePathMappings: remotePathMappings.defaultState, + restrictions: restrictions.defaultState, + ui: ui.defaultState +}; + +export const persistState = [ + 'settings.advancedSettings' +]; + +// +// Actions Types + +export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings'; + +// +// Action Creators + +export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...delayProfiles.actionHandlers, + ...downloadClients.actionHandlers, + ...downloadClientOptions.actionHandlers, + ...general.actionHandlers, + ...indexerOptions.actionHandlers, + ...indexers.actionHandlers, + ...netImportOptions.actionHandlers, + ...netImports.actionHandlers, + ...mediaManagement.actionHandlers, + ...metadata.actionHandlers, + ...naming.actionHandlers, + ...namingExamples.actionHandlers, + ...notifications.actionHandlers, + ...qualityDefinitions.actionHandlers, + ...qualityProfiles.actionHandlers, + ...remotePathMappings.actionHandlers, + ...restrictions.actionHandlers, + ...ui.actionHandlers +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => { + return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); + }, + + ...delayProfiles.reducers, + ...downloadClients.reducers, + ...downloadClientOptions.reducers, + ...general.reducers, + ...indexerOptions.reducers, + ...indexers.reducers, + ...netImportOptions.reducers, + ...netImports.reducers, + ...mediaManagement.reducers, + ...metadata.reducers, + ...naming.reducers, + ...namingExamples.reducers, + ...notifications.reducers, + ...qualityDefinitions.reducers, + ...qualityProfiles.reducers, + ...remotePathMappings.reducers, + ...restrictions.reducers, + ...ui.reducers + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js new file mode 100644 index 000000000..a7c3119b7 --- /dev/null +++ b/frontend/src/Store/Actions/systemActions.js @@ -0,0 +1,392 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { setAppValue } from 'Store/Actions/appActions'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'system'; +const backupsSection = 'system.backups'; + +// +// State + +export const defaultState = { + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + health: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + diskSpace: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + tasks: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + backups: { + isFetching: false, + isPopulated: false, + error: null, + isRestoring: false, + restoreError: null, + isDeleting: false, + deleteError: null, + items: [] + }, + + updates: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + logs: { + isFetching: false, + isPopulated: false, + pageSize: 50, + sortKey: 'time', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'level', + columnLabel: 'Level', + isSortable: false, + isVisible: true, + isModifiable: false + }, + { + name: 'logger', + label: 'Component', + isSortable: false, + isVisible: true, + isModifiable: false + }, + { + name: 'message', + label: 'Message', + isVisible: true, + isModifiable: false + }, + { + name: 'time', + label: 'Time', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isSortable: true, + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'info', + label: 'Info', + filters: [ + { + key: 'level', + value: 'info', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'warn', + label: 'Warn', + filters: [ + { + key: 'level', + value: 'warn', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'error', + label: 'Error', + filters: [ + { + key: 'level', + value: 'error', + type: filterTypes.EQUAL + } + ] + } + ] + }, + + logFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + updateLogFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +export const persistState = [ + 'system.logs.pageSize', + 'system.logs.sortKey', + 'system.logs.sortDirection', + 'system.logs.selectedFilterKey' +]; + +// +// Actions Types + +export const FETCH_STATUS = 'system/status/fetchStatus'; +export const FETCH_HEALTH = 'system/health/fetchHealth'; +export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace'; + +export const FETCH_TASK = 'system/tasks/fetchTask'; +export const FETCH_TASKS = 'system/tasks/fetchTasks'; + +export const FETCH_BACKUPS = 'system/backups/fetchBackups'; +export const RESTORE_BACKUP = 'system/backups/restoreBackup'; +export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup'; +export const DELETE_BACKUP = 'system/backups/deleteBackup'; + +export const FETCH_UPDATES = 'system/updates/fetchUpdates'; + +export const FETCH_LOGS = 'system/logs/fetchLogs'; +export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage'; +export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage'; +export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage'; +export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage'; +export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage'; +export const SET_LOGS_SORT = 'system/logs/setLogsSort'; +export const SET_LOGS_FILTER = 'system/logs/setLogsFilter'; +export const SET_LOGS_TABLE_OPTION = 'system/logs/setLogsTableOption'; +export const CLEAR_LOGS_TABLE = 'system/logs/clearLogsTable'; + +export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles'; +export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles'; + +export const RESTART = 'system/restart'; +export const SHUTDOWN = 'system/shutdown'; + +// +// Action Creators + +export const fetchStatus = createThunk(FETCH_STATUS); +export const fetchHealth = createThunk(FETCH_HEALTH); +export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE); + +export const fetchTask = createThunk(FETCH_TASK); +export const fetchTasks = createThunk(FETCH_TASKS); + +export const fetchBackups = createThunk(FETCH_BACKUPS); +export const restoreBackup = createThunk(RESTORE_BACKUP); +export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP); +export const deleteBackup = createThunk(DELETE_BACKUP); + +export const fetchUpdates = createThunk(FETCH_UPDATES); + +export const fetchLogs = createThunk(FETCH_LOGS); +export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE); +export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE); +export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE); +export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE); +export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE); +export const setLogsSort = createThunk(SET_LOGS_SORT); +export const setLogsFilter = createThunk(SET_LOGS_FILTER); +export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION); +export const clearLogsTable = createAction(CLEAR_LOGS_TABLE); + +export const fetchLogFiles = createThunk(FETCH_LOG_FILES); +export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES); + +export const restart = createThunk(RESTART); +export const shutdown = createThunk(SHUTDOWN); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_STATUS]: createFetchHandler('system.status', '/system/status'), + [FETCH_HEALTH]: createFetchHandler('system.health', '/health'), + [FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'), + [FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'), + [FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'), + + [FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'), + + [RESTORE_BACKUP]: function(getState, payload, dispatch) { + const { + id, + file + } = payload; + + dispatch(set({ + section: backupsSection, + isRestoring: true + })); + + let ajaxOptions = null; + + if (id) { + ajaxOptions = { + url: `/system/backup/restore/${id}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({ + id + }) + }; + } else if (file) { + const formData = new FormData(); + formData.append('restore', file); + + ajaxOptions = { + url: '/system/backup/restore/upload', + method: 'POST', + processData: false, + contentType: false, + data: formData + }; + } else { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: 'Error restoring backup' + })); + } + + const promise = $.ajax(ajaxOptions); + + promise.done((data) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: xhr + })); + }); + }, + + [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'), + + [FETCH_UPDATES]: createFetchHandler('system.updates', '/update'), + [FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'), + [FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'), + + ...createServerSideCollectionHandlers( + 'system.logs', + '/log', + fetchLogs, + { + [serverSideCollectionHandlers.FETCH]: FETCH_LOGS, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE, + [serverSideCollectionHandlers.SORT]: SET_LOGS_SORT, + [serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER + } + ), + + [RESTART]: function(getState, payload, dispatch) { + const promise = $.ajax({ + url: '/system/restart', + method: 'POST' + }); + + promise.done(() => { + dispatch(setAppValue({ isRestarting: true })); + }); + }, + + [SHUTDOWN]: function() { + $.ajax({ + url: '/system/shutdown', + method: 'POST' + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RESTORE_BACKUP]: function(state, { payload }) { + return { + ...state, + backups: { + ...state.backups, + isRestoring: false, + restoreError: null + } + }; + }, + + [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs'), + + [CLEAR_LOGS_TABLE]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js new file mode 100644 index 000000000..b9d217bd3 --- /dev/null +++ b/frontend/src/Store/Actions/tagActions.js @@ -0,0 +1,75 @@ +import $ from 'jquery'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { update } from './baseActions'; + +// +// Variables + +export const section = 'tags'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +// +// Actions Types + +export const FETCH_TAGS = 'tags/fetchTags'; +export const ADD_TAG = 'tags/addTag'; +export const DELETE_TAG = 'tags/deleteTag'; +export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails'; + +// +// Action Creators + +export const fetchTags = createThunk(FETCH_TAGS); +export const addTag = createThunk(ADD_TAG); +export const deleteTag = createThunk(DELETE_TAG); +export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_TAGS]: createFetchHandler(section, '/tag'), + + [ADD_TAG]: function(getState, payload, dispatch) { + const promise = $.ajax({ + url: '/tag', + method: 'POST', + data: JSON.stringify(payload.tag) + }); + + promise.done((data) => { + const tags = getState().tags.items.slice(); + tags.push(data); + + dispatch(update({ section, data: tags })); + payload.onTagCreated(data); + }); + }, + + [DELETE_TAG]: createRemoveItemHandler(section, '/tag'), + [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js new file mode 100644 index 000000000..c7415c8ec --- /dev/null +++ b/frontend/src/Store/Middleware/createPersistState.js @@ -0,0 +1,99 @@ +import _ from 'lodash'; +import persistState from 'redux-localstorage'; +import actions from 'Store/Actions'; + +const columnPaths = []; + +const paths = _.reduce([...actions], (acc, action) => { + if (action.persistState) { + action.persistState.forEach((path) => { + if (path.match(/\.columns$/)) { + columnPaths.push(path); + } + + acc.push(path); + }); + } + + return acc; +}, []); + +function mergeColumns(path, initialState, persistedState, computedState) { + const initialColumns = _.get(initialState, path); + const persistedColumns = _.get(persistedState, path); + + if (!persistedColumns || !persistedColumns.length) { + return; + } + + const columns = []; + + initialColumns.forEach((initialColumn) => { + const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name }); + const column = Object.assign({}, initialColumn); + const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined; + + if (persistedColumn) { + column.isVisible = persistedColumn.isVisible; + } + + // If there is a persisted column, it's index doesn't exceed the column list + // and it's modifiable, insert it in the proper position. + + if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) { + columns.splice(persistedColumnIndex, 0, column); + } else { + columns.push(column); + } + + // Set the columns in the persisted state + _.set(computedState, path, columns); + }); +} + +function slicer(paths_) { + return (state) => { + const subset = {}; + + paths_.forEach((path) => { + _.set(subset, path, _.get(state, path)); + }); + + return subset; + }; +} + +function serialize(obj) { + return JSON.stringify(obj, null, 2); +} + +function merge(initialState, persistedState) { + if (!persistedState) { + return initialState; + } + + const computedState = {}; + + _.merge(computedState, initialState, persistedState); + + columnPaths.forEach((columnPath) => { + mergeColumns(columnPath, initialState, persistedState, computedState); + }); + + return computedState; +} + +const config = { + slicer, + serialize, + merge, + key: 'radarr' +}; + +export default function createPersistState() { + // Migrate existing local storage before proceeding + const persistedState = JSON.parse(localStorage.getItem(config.key)); + localStorage.setItem(config.key, serialize(persistedState)); + + return persistState(paths, config); +} diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js new file mode 100644 index 000000000..afac7e701 --- /dev/null +++ b/frontend/src/Store/Middleware/createSentryMiddleware.js @@ -0,0 +1,93 @@ +import _ from 'lodash'; +import * as sentry from '@sentry/browser'; +import parseUrl from 'Utilities/String/parseUrl'; + +function cleanseUrl(url) { + const properties = parseUrl(url); + + return `${properties.pathname}${properties.search}`; +} + +function cleanseData(data) { + const result = _.cloneDeep(data); + + result.transaction = cleanseUrl(result.transaction); + + if (result.exception) { + result.exception.values.forEach((exception) => { + const stacktrace = exception.stacktrace; + + if (stacktrace) { + stacktrace.frames.forEach((frame) => { + frame.filename = cleanseUrl(frame.filename); + }); + } + }); + } + + result.request.url = cleanseUrl(result.request.url); + + return result; +} + +function identity(stuff) { + return stuff; +} + +function createMiddleware() { + return (store) => (next) => (action) => { + try { + // Adds a breadcrumb for reporting later (if necessary). + sentry.addBreadcrumb({ + category: 'redux', + message: action.type + }); + + return next(action); + } catch (err) { + console.error(`[sentry] Reporting error to Sentry: ${err}`); + + // Send the report including breadcrumbs. + sentry.captureException(err, { + extra: { + action: identity(action), + state: identity(store.getState()) + } + }); + } + }; +} + +export default function createSentryMiddleware() { + const { + // analytics, + branch, + version, + release, + isProduction + } = window.Radarr; + + // TODO update with Radarr Sentry dsn if used. + // if (!analytics) { + if (true) { + return; + } + + const dsn = isProduction ? 'https://b80ca60625b443c38b242e0d21681eb7@sentry.sonarr.tv/13' : + 'https://8dbaacdfe2ff4caf97dc7945aecf9ace@sentry.sonarr.tv/12'; + + sentry.init({ + dsn, + environment: isProduction ? 'production' : 'development', + release, + sendDefaultPii: true, + beforeSend: cleanseData + }); + + sentry.configureScope((scope) => { + scope.setTag('branch', branch); + scope.setTag('version', version); + }); + + return createMiddleware(); +} diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js new file mode 100644 index 000000000..59937bc45 --- /dev/null +++ b/frontend/src/Store/Middleware/middlewares.js @@ -0,0 +1,25 @@ +import { applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import { routerMiddleware } from 'react-router-redux'; +import createSentryMiddleware from './createSentryMiddleware'; +import createPersistState from './createPersistState'; + +export default function(history) { + const middlewares = []; + const sentryMiddleware = createSentryMiddleware(); + + if (sentryMiddleware) { + middlewares.push(sentryMiddleware); + } + + middlewares.push(routerMiddleware(history)); + middlewares.push(thunk); + + // eslint-disable-next-line no-underscore-dangle + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + + return composeEnhancers( + applyMiddleware(...middlewares), + createPersistState() + ); +} diff --git a/frontend/src/Store/Selectors/createAllMoviesSelector.js b/frontend/src/Store/Selectors/createAllMoviesSelector.js new file mode 100644 index 000000000..a6bad0991 --- /dev/null +++ b/frontend/src/Store/Selectors/createAllMoviesSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createAllMoviesSelector() { + return createSelector( + (state) => state.movies, + (movies) => { + return movies.items; + } + ); +} + +export default createAllMoviesSelector; diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js new file mode 100644 index 000000000..36f9d4a56 --- /dev/null +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -0,0 +1,137 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; + +function getSortClause(sortKey, sortDirection, sortPredicates) { + if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) { + return function(item) { + return sortPredicates[sortKey](item, sortDirection); + }; + } + + return function(item) { + return item[sortKey]; + }; +} + +function filter(items, state) { + const { + selectedFilterKey, + filters, + customFilters, + filterPredicates + } = state; + + if (!selectedFilterKey) { + return items; + } + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + return _.filter(items, (item) => { + let i = 0; + let accepted = true; + + while (accepted && i < selectedFilters.length) { + const { + key, + value, + type = filterTypes.EQUAL + } = selectedFilters[i]; + + if (filterPredicates && filterPredicates.hasOwnProperty(key)) { + const predicate = filterPredicates[key]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item, v, type)); + } else { + accepted = predicate(item, value, type); + } + } else if (item.hasOwnProperty(key)) { + const predicate = filterTypePredicates[type]; + + if (Array.isArray(value)) { + if ( + type === filterTypes.NOT_CONTAINS || + type === filterTypes.NOT_EQUAL + ) { + accepted = value.every((v) => predicate(item[key], v)); + } else { + accepted = value.some((v) => predicate(item[key], v)); + } + } else { + accepted = predicate(item[key], value); + } + } else { + // Default to false if the filter can't be tested + accepted = false; + } + + i++; + } + + return accepted; + }); +} + +function sort(items, state) { + const { + sortKey, + sortDirection, + sortPredicates, + secondarySortKey, + secondarySortDirection + } = state; + + const clauses = []; + const orders = []; + + clauses.push(getSortClause(sortKey, sortDirection, sortPredicates)); + orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + + if (secondarySortKey && + secondarySortDirection && + (sortKey !== secondarySortKey || + sortDirection !== secondarySortDirection)) { + clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates)); + orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + } + + return _.orderBy(items, clauses, orders); +} + +function createCustomFiltersSelector(type, alternateType) { + return createSelector( + (state) => state.customFilters.items, + (customFilters) => { + return customFilters.filter((customFilter) => { + return customFilter.type === type || customFilter.type === alternateType; + }); + } + ); +} + +function createClientSideCollectionSelector(section, uiSection) { + return createSelector( + (state) => _.get(state, section), + (state) => _.get(state, uiSection), + createCustomFiltersSelector(section, uiSection), + (sectionState, uiSectionState = {}, customFilters) => { + const state = Object.assign({}, sectionState, uiSectionState, { customFilters }); + + const filtered = filter(state.items, state); + const sorted = sort(filtered, state); + + return { + ...sectionState, + ...uiSectionState, + customFilters, + items: sorted, + totalItems: state.items.length + }; + } + ); +} + +export default createClientSideCollectionSelector; diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js new file mode 100644 index 000000000..6037d5820 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createCommandSelector from './createCommandSelector'; + +function createCommandExecutingSelector(name, contraints = {}) { + return createSelector( + createCommandSelector(name, contraints), + (command) => { + return isCommandExecuting(command); + } + ); +} + +export default createCommandExecutingSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js new file mode 100644 index 000000000..709dfebaf --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import createCommandsSelector from './createCommandsSelector'; + +function createCommandSelector(name, contraints = {}) { + return createSelector( + createCommandsSelector(), + (commands) => { + return findCommand(commands, { name, ...contraints }); + } + ); +} + +export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.js new file mode 100644 index 000000000..7b9edffd9 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createCommandsSelector() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands.items; + } + ); +} + +export default createCommandsSelector; diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.js new file mode 100644 index 000000000..ce26b2e2c --- /dev/null +++ b/frontend/src/Store/Selectors/createDimensionsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createDimensionsSelector() { + return createSelector( + (state) => state.app.dimensions, + (dimensions) => { + return dimensions; + } + ); +} + +export default createDimensionsSelector; diff --git a/frontend/src/Store/Selectors/createExistingMovieSelector.js b/frontend/src/Store/Selectors/createExistingMovieSelector.js new file mode 100644 index 000000000..94bb63687 --- /dev/null +++ b/frontend/src/Store/Selectors/createExistingMovieSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from './createAllMoviesSelector'; + +function createExistingMovieSelector() { + return createSelector( + (state, { tmdbId }) => tmdbId, + createAllMoviesSelector(), + (tmdbId, movies) => { + return _.some(movies, { tmdbId }); + } + ); +} + +export default createExistingMovieSelector; diff --git a/frontend/src/Store/Selectors/createImportMovieItemSelector.js b/frontend/src/Store/Selectors/createImportMovieItemSelector.js new file mode 100644 index 000000000..02fa2558f --- /dev/null +++ b/frontend/src/Store/Selectors/createImportMovieItemSelector.js @@ -0,0 +1,26 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from './createAllMoviesSelector'; + +function createImportMovieItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.addMovie, + (state) => state.importMovie, + createAllMoviesSelector(), + (id, addMovie, importMovie, series) => { + const item = _.find(importMovie.items, { id }) || {}; + const selectedMovie = item && item.selectedMovie; + const isExistingMovie = !!selectedMovie && _.some(series, { tvdbId: selectedMovie.tvdbId }); + + return { + defaultMonitor: addMovie.defaults.monitor, + defaultQualityProfileId: addMovie.defaults.qualityProfileId, + ...item, + isExistingMovie + }; + } + ); +} + +export default createImportMovieItemSelector; diff --git a/frontend/src/Store/Selectors/createMovieCountSelector.js b/frontend/src/Store/Selectors/createMovieCountSelector.js new file mode 100644 index 000000000..e8e76eaa4 --- /dev/null +++ b/frontend/src/Store/Selectors/createMovieCountSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; +import createAllMoviesSelector from './createAllMoviesSelector'; + +function createMovieCountSelector() { + return createSelector( + createAllMoviesSelector(), + (movies) => { + return movies.length; + } + ); +} + +export default createMovieCountSelector; diff --git a/frontend/src/Store/Selectors/createMovieFileSelector.js b/frontend/src/Store/Selectors/createMovieFileSelector.js new file mode 100644 index 000000000..d9b4230ad --- /dev/null +++ b/frontend/src/Store/Selectors/createMovieFileSelector.js @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; + +function createMovieFileSelector() { + return createSelector( + (state, { movieFileId }) => movieFileId, + (state) => state.movieFiles, + (movieFileId, movieFiles) => { + if (!movieFileId) { + return; + } + + return movieFiles.items.find((movieFile) => movieFile.id === movieFileId); + } + ); +} + +export default createMovieFileSelector; diff --git a/frontend/src/Store/Selectors/createMovieSelector.js b/frontend/src/Store/Selectors/createMovieSelector.js new file mode 100644 index 000000000..306547cf7 --- /dev/null +++ b/frontend/src/Store/Selectors/createMovieSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from './createAllMoviesSelector'; + +function createMovieSelector() { + return createSelector( + (state, { movieId }) => movieId, + createAllMoviesSelector(), + (movieId, movies) => { + return _.find(movies, { id: movieId }); + } + ); +} + +export default createMovieSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js new file mode 100644 index 000000000..baa6853e0 --- /dev/null +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from './createAllMoviesSelector'; + +function createProfileInUseSelector(profileProp) { + return createSelector( + (state, { id }) => id, + createAllMoviesSelector(), + (id, series) => { + if (!id) { + return false; + } + + return _.some(series, { [profileProp]: id }); + } + ); +} + +export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js new file mode 100644 index 000000000..46659609f --- /dev/null +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -0,0 +1,63 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createProviderSettingsSelector(sectionName) { + return createSelector( + (state, { id }) => id, + (state) => state.settings[sectionName], + (id, section) => { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; + } + ); +} + +export default createProviderSettingsSelector; diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js new file mode 100644 index 000000000..9308d63ac --- /dev/null +++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js @@ -0,0 +1,14 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; + +function createQualityProfileSelector() { + return createSelector( + (state, { qualityProfileId }) => qualityProfileId, + (state) => state.settings.qualityProfiles.items, + (qualityProfileId, qualityProfiles) => { + return _.find(qualityProfiles, { id: qualityProfileId }); + } + ); +} + +export default createQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js new file mode 100644 index 000000000..f2ff2b907 --- /dev/null +++ b/frontend/src/Store/Selectors/createQueueItemSelector.js @@ -0,0 +1,23 @@ +import { createSelector } from 'reselect'; + +function createQueueItemSelector() { + return createSelector( + (state, { episodeId }) => episodeId, + (state) => state.queue.details.items, + (episodeId, details) => { + if (!episodeId) { + return null; + } + + return details.find((item) => { + if (item.episode) { + return item.episode.id === episodeId; + } + + return false; + }); + } + ); +} + +export default createQueueItemSelector; diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js new file mode 100644 index 000000000..a9f6cbff6 --- /dev/null +++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createSettingsSectionSelector(section) { + return createSelector( + (state) => state.settings[section], + (sectionSettings) => { + const { + isFetching, + isPopulated, + error, + item, + pendingChanges, + isSaving, + saveError + } = sectionSettings; + + const settings = selectSettings(item, pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + ...settings + }; + } + ); +} + +export default createSettingsSectionSelector; diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.js new file mode 100644 index 000000000..df586bbb9 --- /dev/null +++ b/frontend/src/Store/Selectors/createSystemStatusSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createSystemStatusSelector() { + return createSelector( + (state) => state.system.status, + (status) => { + return status.item; + } + ); +} + +export default createSystemStatusSelector; diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.js new file mode 100644 index 000000000..dd178944c --- /dev/null +++ b/frontend/src/Store/Selectors/createTagDetailsSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; + +function createTagDetailsSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.tags.details.items, + (id, tagDetails) => { + return tagDetails.find((t) => t.id === id); + } + ); +} + +export default createTagDetailsSelector; diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.js new file mode 100644 index 000000000..fbfd91cdb --- /dev/null +++ b/frontend/src/Store/Selectors/createTagsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createTagsSelector() { + return createSelector( + (state) => state.tags.items, + (tags) => { + return tags; + } + ); +} + +export default createTagsSelector; diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.js new file mode 100644 index 000000000..b256d0e98 --- /dev/null +++ b/frontend/src/Store/Selectors/createUISettingsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createUISettingsSelector() { + return createSelector( + (state) => state.settings.ui, + (ui) => { + return ui.item; + } + ); +} + +export default createUISettingsSelector; diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js new file mode 100644 index 000000000..3e30478b7 --- /dev/null +++ b/frontend/src/Store/Selectors/selectSettings.js @@ -0,0 +1,104 @@ +import _ from 'lodash'; + +function getValidationFailures(saveError) { + if (!saveError || saveError.status !== 400) { + return []; + } + + return _.cloneDeep(saveError.responseJSON); +} + +function mapFailure(failure) { + return { + message: failure.errorMessage, + link: failure.infoLink, + detailedMessage: failure.detailedDescription + }; +} + +function selectSettings(item, pendingChanges, saveError) { + const validationFailures = getValidationFailures(saveError); + + // Merge all settings from the item along with pending + // changes to ensure any settings that were not included + // with the item are included. + const allSettings = Object.assign({}, item, pendingChanges); + + const settings = _.reduce(allSettings, (result, value, key) => { + if (key === 'fields') { + return result; + } + + // Return a flattened value + if (key === 'implementationName') { + result.implementationName = item[key]; + + return result; + } + + const setting = { + value: item[key], + errors: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning; + }), mapFailure), + + warnings: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning; + }), mapFailure) + }; + + if (pendingChanges.hasOwnProperty(key)) { + setting.previousValue = setting.value; + setting.value = pendingChanges[key]; + setting.pending = true; + } + + result[key] = setting; + return result; + }, {}); + + const fields = _.reduce(item.fields, (result, f) => { + const field = Object.assign({ pending: false }, f); + const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name); + + if (hasPendingFieldChange) { + field.previousValue = field.value; + field.value = pendingChanges.fields[field.name]; + field.pending = true; + } + + field.errors = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning; + }), mapFailure); + + field.warnings = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning; + }), mapFailure); + + result.push(field); + return result; + }, []); + + if (fields.length) { + settings.fields = fields; + } + + const validationErrors = _.filter(validationFailures, (failure) => { + return !failure.isWarning; + }); + + const validationWarnings = _.filter(validationFailures, (failure) => { + return failure.isWarning; + }); + + return { + settings, + validationErrors, + validationWarnings, + hasPendingChanges: !_.isEmpty(pendingChanges), + hasSettings: !_.isEmpty(settings), + pendingChanges + }; +} + +export default selectSettings; diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js new file mode 100644 index 000000000..e05c80323 --- /dev/null +++ b/frontend/src/Store/createAppStore.js @@ -0,0 +1,15 @@ +import { createStore } from 'redux'; +import reducers, { defaultState } from 'Store/Actions/reducers'; +import middlewares from 'Store/Middleware/middlewares'; + +function createAppStore(history) { + const appStore = createStore( + reducers, + defaultState, + middlewares(history) + ); + + return appStore; +} + +export default createAppStore; diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js new file mode 100644 index 000000000..4f926b7c0 --- /dev/null +++ b/frontend/src/Store/scrollPositions.js @@ -0,0 +1,5 @@ +const scrollPositions = { + movieIndex: 0 +}; + +export default scrollPositions; diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js new file mode 100644 index 000000000..ebcf10917 --- /dev/null +++ b/frontend/src/Store/thunks.js @@ -0,0 +1,28 @@ +const thunks = {}; + +function identity(payload) { + return payload; +} + +export function createThunk(type, identityFunction = identity) { + return function(payload = {}) { + return function(dispatch, getState) { + const thunk = thunks[type]; + + if (thunk) { + return thunk(getState, identityFunction(payload), dispatch); + } + + throw Error(`Thunk handler has not been registered for ${type}`); + }; + }; +} + +export function handleThunks(handlers) { + const types = Object.keys(handlers); + + types.forEach((type) => { + thunks[type] = handlers[type]; + }); +} + diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css new file mode 100644 index 000000000..e44c99be6 --- /dev/null +++ b/frontend/src/Styles/Mixins/cover.css @@ -0,0 +1,8 @@ +@define-mixin cover { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; +} diff --git a/frontend/src/Styles/Mixins/linkOverlay.css b/frontend/src/Styles/Mixins/linkOverlay.css new file mode 100644 index 000000000..74c3fd753 --- /dev/null +++ b/frontend/src/Styles/Mixins/linkOverlay.css @@ -0,0 +1,11 @@ +@define-mixin linkOverlay { + @add-mixin cover; + + pointer-events: none; + user-select: none; + + a, + button { + pointer-events: all; + } +} diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css new file mode 100644 index 000000000..62a619103 --- /dev/null +++ b/frontend/src/Styles/Mixins/scroller.css @@ -0,0 +1,26 @@ +@define-mixin scrollbar { + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } +} + +@define-mixin scrollbarTrack { + &&::-webkit-scrollbar-track { + background-color: transparent; + } +} + +@define-mixin scrollbarThumb { + &::-webkit-scrollbar-thumb { + min-height: 50px; + border: 1px solid transparent; + border-radius: 5px; + background-color: $scrollbarBackgroundColor; + background-clip: padding-box; + + &:hover { + background-color: $scrollbarHoverBackgroundColor; + } + } +} diff --git a/frontend/src/Styles/Mixins/truncate.css b/frontend/src/Styles/Mixins/truncate.css new file mode 100644 index 000000000..1941afc9b --- /dev/null +++ b/frontend/src/Styles/Mixins/truncate.css @@ -0,0 +1,18 @@ +/** + * From: https://github.com/suitcss/utils-text/blob/master/lib/text.css + * + * Text truncation + * + * Prevent text from wrapping onto multiple lines, and truncate with an + * ellipsis. + * + * 1. Ensure that the node has a maximum width after which truncation can + * occur. + */ + +@define-mixin truncate { + overflow: hidden !important; + max-width: 100%; /* 1 */ + text-overflow: ellipsis !important; + white-space: nowrap !important; +} diff --git a/frontend/src/Styles/Variables/animations.js b/frontend/src/Styles/Variables/animations.js new file mode 100644 index 000000000..52d12827a --- /dev/null +++ b/frontend/src/Styles/Variables/animations.js @@ -0,0 +1,8 @@ +// Use CommonJS since this is consumed by PostCSS via webpack (node.js). + +module.exports = { + // Durations + defaultSpeed: '0.2s', + slowSpeed: '0.6s', + fastSpeed: '0.1s' +}; diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js new file mode 100644 index 000000000..8a316dcd5 --- /dev/null +++ b/frontend/src/Styles/Variables/colors.js @@ -0,0 +1,180 @@ +const radarrYellow = '#ffc230'; + +module.exports = { + defaultColor: '#333', + disabledColor: '#999', + dimColor: '#555', + black: '#000', + white: '#fff', + offWhite: '#f5f7fa', + primaryColor: '#5d9cec', + selectedColor: '#f9be03', + successColor: '#27c24c', + dangerColor: '#f05050', + warningColor: '#ffa500', + infoColor: '#5d9cec', + purple: '#7a43b6', + pink: '#ff69b4', + radarrYellow, + helpTextColor: '#909293', + darkGray: '#888', + gray: '#adadad', + lightGray: '#ddd', + disabledInputColor: '#808080', + + // Theme Colors + + themeBlue: radarrYellow, + themeRed: '#c4273c', + themeDarkColor: '#595959', + themeLightColor: '#707070', + + torrentColor: '#00853d', + usenetColor: '#17b1d9', + + // Links + defaultLinkHoverColor: '#fff', + linkColor: '#5d9cec', + linkHoverColor: '#1b72e2', + + // Sidebar + + sidebarColor: '#e1e2e3', + sidebarBackgroundColor: '#595959', + sidebarActiveBackgroundColor: '#333333', + + // Toolbar + toolbarColor: '#e1e2e3', + toolbarBackgroundColor: '#707070', + toolbarMenuItemBackgroundColor: '#606060', + toolbarMenuItemHoverBackgroundColor: '#515151', + toolbarLabelColor: '#e1e2e3', + + // Accents + borderColor: '#e5e5e5', + inputBorderColor: '#dde6e9', + inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)', + inputFocusBorderColor: '#66afe9', + inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)', + inputErrorBorderColor: '#f05050', + inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)', + inputWarningBorderColor: '#ffa500', + inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)', + colorImpairedGradient: '#fcfcfc', + + // + // Buttons + + defaultBackgroundColor: '#fff', + defaultBorderColor: '#eaeaea', + defaultHoverBackgroundColor: '#f5f5f5', + defaultHoverBorderColor: '#d6d6d6;', + + primaryBackgroundColor: '#5d9cec', + primaryBorderColor: '#5899eb', + primaryHoverBackgroundColor: '#4b91ea', + primaryHoverBorderColor: '#3483e7;', + + successBackgroundColor: '#27c24c', + successBorderColor: '#26be4a', + successHoverBackgroundColor: '#24b145', + successHoverBorderColor: '#1f9c3d;', + + warningBackgroundColor: '#ff902b', + warningBorderColor: '#ff8d26', + warningHoverBackgroundColor: '#ff8517', + warningHoverBorderColor: '#fc7800;', + + dangerBackgroundColor: '#f05050', + dangerBorderColor: '#f04b4b', + dangerHoverBackgroundColor: '#ee3d3d', + dangerHoverBorderColor: '#ec2626;', + + iconButtonDisabledColor: '#7a7a7a', + iconButtonHoverColor: '#666', + iconButtonHoverLightColor: '#ccc', + + // + // Modal + + modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)', + modalBackgroundColor: '#fff', + modalCloseButtonHoverColor: '#888', + + // + // Menu + menuItemColor: '#e1e2e3', + menuItemHoverColor: '#fbfcfc', + menuItemHoverBackgroundColor: '#f5f7fa', + + // + // Toolbar + + toobarButtonHoverColor: '#ffc230', + toobarButtonSelectedColor: '#ffc230', + + // + // Scroller + + scrollbarBackgroundColor: '#9ea4b9', + scrollbarHoverBackgroundColor: '#656d8c', + + // + // Card + + cardShadowColor: '#e1e1e1', + cardAlternateBackgroundColor: '#f5f5f5', + + // + // Alert + + alertDangerBorderColor: '#ebccd1', + alertDangerBackgroundColor: '#f2dede', + alertDangerColor: '#a94442', + + alertInfoBorderColor: '#bce8f1', + alertInfoBackgroundColor: '#d9edf7', + alertInfoColor: '#31708f', + + alertSuccessBorderColor: '#d6e9c6', + alertSuccessBackgroundColor: '#dff0d8', + alertSuccessColor: '#3c763d', + + alertWarningBorderColor: '#faebcc', + alertWarningBackgroundColor: '#fcf8e3', + alertWarningColor: '#8a6d3b', + + // + // Slider + + sliderAccentColor: '#5d9cec', + + // + // Form + + advancedFormLabelColor: '#ff902b', + disabledCheckInputColor: '#ddd', + + // + // Popover + + popoverTitleBackgroundColor: '#f7f7f7', + popoverTitleBorderColor: '#ebebeb', + popoverShadowColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)', + + popoverTitleBackgroundInverseColor: '#3a3f51', + popoverTitleBorderInverseColor: '#4f566f', + popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)', + + // + // Calendar + + calendarTodayBackgroundColor: '#ddd', + + // + // Table + + tableRowHoverBackgroundColor: '#fafbfc' +}; diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js new file mode 100644 index 000000000..5d6a116db --- /dev/null +++ b/frontend/src/Styles/Variables/dimensions.js @@ -0,0 +1,53 @@ +module.exports = { + // Page + pageContentBodyPadding: '20px', + pageContentBodyPaddingSmallScreen: '10px', + + // Header + headerHeight: '60px', + + // Sidebar + sidebarWidth: '210px', + + // Toolbar + toolbarHeight: '60px', + toolbarButtonWidth: '60px', + toolbarSeparatorMargin: '20px', + + // Break Points + breakpointExtraSmall: '480px', + breakpointSmall: '768px', + breakpointMedium: '992px', + breakpointLarge: '1200px', + breakpointExtraLarge: '1450px', + + // Form + formGroupExtraSmallWidth: '550px', + formGroupSmallWidth: '650px', + formGroupMediumWidth: '800px', + formGroupLargeWidth: '1200px', + formLabelSmallWidth: '150px', + formLabelLargeWidth: '250px', + formLabelRightMarginWidth: '20px', + + // Drag + dragHandleWidth: '40px', + qualityProfileItemHeight: '30px', + qualityProfileItemDragSourcePadding: '4px', + + // Progress Bar + progressBarSmallHeight: '5px', + progressBarMediumHeight: '15px', + progressBarLargeHeight: '20px', + + // Jump Bar + jumpBarItemHeight: '25px', + + // Modal + modalBodyPadding: '30px', + + // Series + movieIndexColumnPadding: '20px', + movieIndexColumnPaddingSmallScreen: '10px', + movieIndexOverviewInfoRowHeight: '21px' +}; diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js new file mode 100644 index 000000000..3b0077c5a --- /dev/null +++ b/frontend/src/Styles/Variables/fonts.js @@ -0,0 +1,15 @@ +module.exports = { + // Families + defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', + monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', + passwordFamily: 'text-security-disc', + + // Sizes + extraSmallFontSize: '11px', + smallFontSize: '12px', + defaultFontSize: '14px', + intermediateFontSize: '15px', + largeFontSize: '16px', + + lineHeight: '1.528571429' +}; diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css new file mode 100644 index 000000000..70967f0c4 --- /dev/null +++ b/frontend/src/Styles/globals.css @@ -0,0 +1,7 @@ +/* stylelint-disable */ + +@import '~normalize.css/normalize.css'; +@import 'scaffolding.css'; +@import '../Content/Fonts/fonts.css'; + +/* stylelint-enable */ diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css new file mode 100644 index 000000000..1810037cf --- /dev/null +++ b/frontend/src/Styles/scaffolding.css @@ -0,0 +1,54 @@ +/* stylelint-disable */ +* { + box-sizing: border-box; +} + +*::before, +*::after { + box-sizing: border-box; +} + +*:focus { + outline: none; +} +/* stylelint-enable */ + +html, +body { + color: #515253; + font-family: 'Roboto', 'open sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; +} + +body { + font-size: 14px; + line-height: 1.528571429; /* 20/14 */ +} + +/* Override normalize */ + +button, +input, +optgroup, +select, +textarea { + margin: 0; + font-size: inherit; + font-family: inherit; + line-height: 1.528571429; /* 20/14 */ +} + +/* Better defaults for unordererd lists */ + +ul { + margin: 0; + padding-left: 20px; +} + +@media only screen and (min-device-width: 375px) and (max-device-width: 812px) { + input, + optgroup, + select, + textarea { + font-size: 16px; + } +} diff --git a/frontend/src/System/Backup/BackupRow.css b/frontend/src/System/Backup/BackupRow.css new file mode 100644 index 000000000..d83a22e25 --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.css @@ -0,0 +1,12 @@ +.type { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; + text-align: center; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js new file mode 100644 index 000000000..df5ff974c --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.js @@ -0,0 +1,153 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; +import styles from './BackupRow.css'; + +class BackupRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onConfirmDeletePress = () => { + const { + id, + onDeleteBackupPress + } = this.props; + + this.setState({ isConfirmDeleteModalOpen: false }, () => { + onDeleteBackupPress(id); + }); + } + + // + // Render + + render() { + const { + id, + type, + name, + path, + time + } = this.props; + + const { + isRestoreModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + let iconClassName = icons.SCHEDULED; + let iconTooltip = 'Scheduled'; + + if (type === 'manual') { + iconClassName = icons.INTERACTIVE; + iconTooltip = 'Manual'; + } else if (type === 'update') { + iconClassName = icons.UPDATE; + iconTooltip = 'Before update'; + } + + return ( + + + { + + } + + + + + {name} + + + + + + + + + + + + + + + + ); + } +} + +BackupRow.propTypes = { + id: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default BackupRow; diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js new file mode 100644 index 000000000..97167e6f2 --- /dev/null +++ b/frontend/src/System/Backup/Backups.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BackupRow from './BackupRow'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; + +const columns = [ + { + name: 'type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'time', + label: 'Time', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +class Backups extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + backupExecuting, + onBackupPress, + onDeleteBackupPress + } = this.props; + + const hasBackups = isPopulated && !!items.length; + const noBackups = isPopulated && !items.length; + + return ( + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load backups
+ } + + { + noBackups && +
No backups are available
+ } + + { + hasBackups && + + + { + items.map((item) => { + const { + id, + type, + name, + path, + time + } = item; + + return ( + + ); + }) + } + +
+ } +
+ + +
+ ); + } + +} + +Backups.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + backupExecuting: PropTypes.bool.isRequired, + onBackupPress: PropTypes.func.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default Backups; diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js new file mode 100644 index 000000000..434354f5b --- /dev/null +++ b/frontend/src/System/Backup/BackupsConnector.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Backups from './Backups'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + createCommandExecutingSelector(commandNames.BACKUP), + (backups, backupExecuting) => { + const { + isFetching, + isPopulated, + error, + items + } = backups; + + return { + isFetching, + isPopulated, + error, + items, + backupExecuting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchBackups() { + dispatch(fetchBackups()); + }, + + onDeleteBackupPress(id) { + dispatch(deleteBackup({ id })); + }, + + onBackupPress() { + dispatch(executeCommand({ + name: commandNames.BACKUP + })); + } + }; +} + +class BackupsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchBackups(); + } + + componentDidUpdate(prevProps) { + if (prevProps.backupExecuting && !this.props.backupExecuting) { + this.props.dispatchFetchBackups(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +BackupsConnector.propTypes = { + backupExecuting: PropTypes.bool.isRequired, + dispatchFetchBackups: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector); diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js new file mode 100644 index 000000000..48dad4d2a --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector'; + +function RestoreBackupModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +RestoreBackupModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModal; diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js new file mode 100644 index 000000000..98cbcd11b --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { clearRestoreBackup } from 'Store/Actions/systemActions'; +import RestoreBackupModal from './RestoreBackupModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(clearRestoreBackup()); + + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(RestoreBackupModal); diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css new file mode 100644 index 000000000..783443e33 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.css @@ -0,0 +1,24 @@ +.additionalInfo { + flex-grow: 1; + color: #777; +} + +.steps { + margin-top: 20px; +} + +.step { + display: flex; + font-size: $largeFontSize; + line-height: 20px; +} + +.stepState { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; + + flex-wrap: wrap; +} diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js new file mode 100644 index 000000000..274e657e5 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.js @@ -0,0 +1,232 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TextInput from 'Components/Form/TextInput'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RestoreBackupModalContent.css'; + +function getErrorMessage(error) { + if (!error || !error.responseJSON || !error.responseJSON.message) { + return 'Error restoring backup'; + } + + return error.responseJSON.message; +} + +function getStepIconProps(isExecuting, hasExecuted, error) { + if (isExecuting) { + return { + name: icons.SPINNER, + isSpinning: true + }; + } + + if (hasExecuted) { + return { + name: icons.CHECK, + kind: kinds.SUCCESS + }; + } + + if (error) { + return { + name: icons.FATAL, + kinds: kinds.DANGER, + title: getErrorMessage(error) + }; + } + + return { + name: icons.PENDING + }; +} + +class RestoreBackupModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + file: null, + path: '', + isRestored: false, + isRestarted: false, + isReloading: false + }; + } + + componentDidUpdate(prevProps) { + const { + isRestoring, + restoreError, + isRestarting, + dispatchRestart + } = this.props; + + if (prevProps.isRestoring && !isRestoring && !restoreError) { + this.setState({ isRestored: true }, () => { + dispatchRestart(); + }); + } + + if (prevProps.isRestarting && !isRestarting) { + this.setState({ + isRestarted: true, + isReloading: true + }, () => { + location.reload(); + }); + } + } + + // + // Listeners + + onPathChange = ({ value, files }) => { + this.setState({ + file: files[0], + path: value + }); + } + + onRestorePress = () => { + const { + id, + onRestorePress + } = this.props; + + onRestorePress({ + id, + file: this.state.file + }); + } + + // + // Render + + render() { + const { + id, + name, + isRestoring, + restoreError, + isRestarting, + onModalClose + } = this.props; + + const { + path, + isRestored, + isRestarted, + isReloading + } = this.state; + + const isRestoreDisabled = ( + (!id && !path) || + isRestoring || + isRestarting || + isReloading + ); + + return ( + + + Restore Backup + + + + { + !!id && `Would you like to restore the backup '${name}'?` + } + + { + !id && + + } + +
+
+
+ +
+ +
Restore
+
+ +
+
+ +
+ +
Restart
+
+ +
+
+ +
+ +
Reload
+
+
+
+ + +
+ Note: Radarr will automatically restart and reload the UI during the restore process. +
+ + + + + Restore + +
+
+ ); + } +} + +RestoreBackupModalContent.propTypes = { + id: PropTypes.number, + name: PropTypes.string, + path: PropTypes.string, + isRestoring: PropTypes.bool.isRequired, + restoreError: PropTypes.object, + isRestarting: PropTypes.bool.isRequired, + dispatchRestart: PropTypes.func.isRequired, + onRestorePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModalContent; diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js new file mode 100644 index 000000000..7f2b7a6e8 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { restoreBackup, restart } from 'Store/Actions/systemActions'; +import RestoreBackupModalContent from './RestoreBackupModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + (state) => state.app.isRestarting, + (backups, isRestarting) => { + const { + isRestoring, + restoreError + } = backups; + + return { + isRestoring, + restoreError, + isRestarting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRestorePress(payload) { + dispatch(restoreBackup(payload)); + }, + + dispatchRestart() { + dispatch(restart()); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent); diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js new file mode 100644 index 000000000..ce6d0c995 --- /dev/null +++ b/frontend/src/System/Events/LogsTable.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import LogsTableRow from './LogsTableRow'; + +function LogsTable(props) { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + clearLogExecuting, + onRefreshPress, + onClearLogsPress, + onFilterSelect, + ...otherProps + } = props; + + return ( + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + isPopulated && !error && !items.length && +
+ No logs found +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); +} + +LogsTable.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + clearLogExecuting: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onClearLogsPress: PropTypes.func.isRequired +}; + +export default LogsTable; diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js new file mode 100644 index 000000000..d2cb6caf8 --- /dev/null +++ b/frontend/src/System/Events/LogsTableConnector.js @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as systemActions from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogsTable from './LogsTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logs, + createCommandExecutingSelector(commandNames.CLEAR_LOGS), + (logs, clearLogExecuting) => { + return { + clearLogExecuting, + ...logs + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand, + ...systemActions +}; + +class LogsTableConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchLogs, + gotoLogsFirstPage + } = this.props; + + if (useCurrentPage) { + fetchLogs(); + } else { + gotoLogsFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) { + this.props.gotoLogsFirstPage(); + } + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoLogsFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoLogsPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoLogsNextPage(); + } + + onLastPagePress = () => { + this.props.gotoLogsLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoLogsPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setLogsSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setLogsFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setLogsTableOption(payload); + + if (payload.pageSize) { + this.props.gotoLogsFirstPage(); + } + } + + onRefreshPress = () => { + this.props.gotoLogsFirstPage(); + } + + onClearLogsPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_LOGS }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogsTableConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + clearLogExecuting: PropTypes.bool.isRequired, + fetchLogs: PropTypes.func.isRequired, + gotoLogsFirstPage: PropTypes.func.isRequired, + gotoLogsPreviousPage: PropTypes.func.isRequired, + gotoLogsNextPage: PropTypes.func.isRequired, + gotoLogsLastPage: PropTypes.func.isRequired, + gotoLogsPage: PropTypes.func.isRequired, + setLogsSort: PropTypes.func.isRequired, + setLogsFilter: PropTypes.func.isRequired, + setLogsTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector) +); diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css new file mode 100644 index 000000000..127c1139f --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.css @@ -0,0 +1,17 @@ +.detailsText { + composes: scroller from 'Components/Scroller/Scroller.css'; + + display: block; + margin: 0 0 10.5px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + color: #3a3f51; + white-space: pre; + word-wrap: break-word; + word-break: break-all; + font-size: 13px; + font-family: $monoSpaceFontFamily; + line-height: 1.52857143; +} diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js new file mode 100644 index 000000000..de6a881df --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './LogsTableDetailsModal.css'; + +function LogsTableDetailsModal(props) { + const { + isOpen, + message, + exception, + onModalClose + } = props; + + return ( + + + + Details + + + +
Message
+ + + {message} + + + { + !!exception && +
+
Exception
+ + {exception} + +
+ } +
+ + + + +
+
+ ); +} + +LogsTableDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + message: PropTypes.string.isRequired, + exception: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default LogsTableDetailsModal; diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css new file mode 100644 index 000000000..557d690f0 --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.css @@ -0,0 +1,35 @@ +.level { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} + +.info { + color: #1e90ff; +} + +.debug { + color: #808080; +} + +.trace { + color: #d3d3d3; +} + +.warn { + color: $warningColor; +} + +.error { + color: $dangerColor; +} + +.fatal { + color: $purple; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js new file mode 100644 index 000000000..390769727 --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.js @@ -0,0 +1,158 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import LogsTableDetailsModal from './LogsTableDetailsModal'; +import styles from './LogsTableRow.css'; + +function getIconName(level) { + switch (level) { + case 'trace': + case 'debug': + case 'info': + return icons.INFO; + case 'warn': + return icons.DANGER; + case 'error': + return icons.BUG; + case 'fatal': + return icons.FATAL; + default: + return icons.UNKNOWN; + } +} + +class LogsTableRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + // Don't re-open the modal if it's already open + if (!this.state.isDetailsModalOpen) { + this.setState({ isDetailsModalOpen: true }); + } + } + + onModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + level, + logger, + message, + time, + exception, + columns + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'level') { + return ( + + + + ); + } + + if (name === 'logger') { + return ( + + {logger} + + ); + } + + if (name === 'message') { + return ( + + {message} + + ); + } + + if (name === 'time') { + return ( + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + + + ); + } + +} + +LogsTableRow.propTypes = { + level: PropTypes.string.isRequired, + logger: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + exception: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default LogsTableRow; diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js new file mode 100644 index 000000000..47482b3fe --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFiles.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import TableBody from 'Components/Table/TableBody'; +import LogsNavMenu from '../LogsNavMenu'; +import LogFilesTableRow from './LogFilesTableRow'; + +const columns = [ + { + name: 'filename', + label: 'Filename', + isVisible: true + }, + { + name: 'lastWriteTime', + label: 'Last Write Time', + isVisible: true + }, + { + name: 'download', + isVisible: true + } +]; + +class LogFiles extends Component { + + // + // Render + + render() { + const { + isFetching, + items, + deleteFilesExecuting, + currentLogView, + location, + onRefreshPress, + onDeleteFilesPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + + + + + + +
+ Log files are located in: {location} +
+ + { + currentLogView === 'Log Files' && +
+ The log level defaults to 'Info' and can be changed in General Settings +
+ } +
+ + { + isFetching && + + } + + { + !isFetching && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !isFetching && !items.length && +
No log files
+ } +
+
+ ); + } + +} + +LogFiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + deleteFilesExecuting: PropTypes.bool.isRequired, + currentLogView: PropTypes.string.isRequired, + location: PropTypes.string.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onDeleteFilesPress: PropTypes.func.isRequired +}; + +export default LogFiles; diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js new file mode 100644 index 000000000..628bb571c --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from './LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_LOG_FILES), + (logFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = logFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Log Files', + location: combinePath(isWindows, appData, ['logs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchLogFiles, + executeCommand +}; + +class LogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector); diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css new file mode 100644 index 000000000..779794b7d --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css @@ -0,0 +1,5 @@ +.download { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js new file mode 100644 index 000000000..7ae61a531 --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './LogFilesTableRow.css'; + +class LogFilesTableRow extends Component { + + // + // Render + + render() { + const { + filename, + lastWriteTime, + downloadUrl + } = this.props; + + return ( + + {filename} + + + + + + Download + + + + ); + } + +} + +LogFilesTableRow.propTypes = { + filename: PropTypes.string.isRequired, + lastWriteTime: PropTypes.string.isRequired, + downloadUrl: PropTypes.string.isRequired +}; + +export default LogFilesTableRow; diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js new file mode 100644 index 000000000..fa0be453e --- /dev/null +++ b/frontend/src/System/Logs/Logs.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import LogFilesConnector from './Files/LogFilesConnector'; +import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector'; + +class Logs extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default Logs; diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js new file mode 100644 index 000000000..b69630248 --- /dev/null +++ b/frontend/src/System/Logs/LogsNavMenu.js @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; + +class LogsNavMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMenuOpen: false + }; + } + + // + // Listeners + + onMenuButtonPress = () => { + this.setState({ isMenuOpen: !this.state.isMenuOpen }); + } + + onMenuItemPress = () => { + this.setState({ isMenuOpen: false }); + } + + // + // Render + + render() { + const { + current + } = this.props; + + return ( + + + {current} + + + + Log Files + + + + Updater Log Files + + + + ); + } +} + +LogsNavMenu.propTypes = { + current: PropTypes.string.isRequired +}; + +export default LogsNavMenu; diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js new file mode 100644 index 000000000..3030c12ce --- /dev/null +++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchUpdateLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from '../Files/LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.updateLogFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES), + (updateLogFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = updateLogFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Updater Log Files', + location: combinePath(isWindows, appData, ['UpdateLogs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdateLogFiles, + executeCommand +}; + +class UpdateLogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdateLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchUpdateLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchUpdateLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdateLogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchUpdateLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector); diff --git a/frontend/src/System/Status/About/About.css b/frontend/src/System/Status/About/About.css new file mode 100644 index 000000000..fc20848b0 --- /dev/null +++ b/frontend/src/System/Status/About/About.css @@ -0,0 +1,5 @@ +.descriptionList { + composes: descriptionList from 'Components/DescriptionList/DescriptionList.css'; + + margin-bottom: 10px; +} diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js new file mode 100644 index 000000000..0965baff4 --- /dev/null +++ b/frontend/src/System/Status/About/About.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import StartTime from './StartTime'; +import styles from './About.css'; + +class About extends Component { + + // + // Render + + render() { + const { + version, + isMonoRuntime, + runtimeVersion, + appData, + startupPath, + mode, + startTime, + timeFormat, + longDateFormat + } = this.props; + + return ( +
+ + + + { + isMonoRuntime && + + } + + + + + + + + + } + /> + +
+ ); + } + +} + +About.propTypes = { + version: PropTypes.string.isRequired, + isMonoRuntime: PropTypes.bool.isRequired, + runtimeVersion: PropTypes.string.isRequired, + appData: PropTypes.string.isRequired, + startupPath: PropTypes.string.isRequired, + mode: PropTypes.string.isRequired, + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default About; diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js new file mode 100644 index 000000000..475d9778b --- /dev/null +++ b/frontend/src/System/Status/About/AboutConnector.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import About from './About'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.status, + createUISettingsSelector(), + (status, uiSettings) => { + return { + ...status.item, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchStatus +}; + +class AboutConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchStatus(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AboutConnector.propTypes = { + fetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector); diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js new file mode 100644 index 000000000..94b4322d5 --- /dev/null +++ b/frontend/src/System/Status/About/StartTime.js @@ -0,0 +1,93 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; + +function getUptime(startTime) { + return formatTimeSpan(moment().diff(startTime)); +} + +class StartTime extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + startTime, + timeFormat, + longDateFormat + } = props; + + this._timeoutId = null; + + this.state = { + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }; + } + + componentDidMount() { + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + componentDidUpdate(prevProps) { + const { + startTime, + timeFormat, + longDateFormat + } = this.props; + + if ( + startTime !== prevProps.startTime || + timeFormat !== prevProps.timeFormat || + longDateFormat !== prevProps.longDateFormat + ) { + this.setState({ + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }); + } + } + + componentWillUnmount() { + if (this._timeoutId) { + this._timeoutId = clearTimeout(this._timeoutId); + } + } + + // + // Listeners + + onTimeout = () => { + this.setState({ uptime: getUptime(this.props.startTime) }); + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + // + // Render + + render() { + const { + uptime, + startTime + } = this.state; + + return ( + + {uptime} + + ); + } +} + +StartTime.propTypes = { + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default StartTime; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css new file mode 100644 index 000000000..70ef6f884 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css @@ -0,0 +1,5 @@ +.space { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js new file mode 100644 index 000000000..ad1de4d64 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js @@ -0,0 +1,120 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import formatBytes from 'Utilities/Number/formatBytes'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './DiskSpace.css'; + +const columns = [ + { + name: 'path', + label: 'Location', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'totalSpace', + label: 'Total Space', + isVisible: true + }, + { + name: 'progress', + isVisible: true + } +]; + +class DiskSpace extends Component { + + // + // Render + + render() { + const { + isFetching, + items + } = this.props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && + + + { + items.map((item) => { + const { + freeSpace, + totalSpace + } = item; + + const diskUsage = (100 - freeSpace / totalSpace * 100); + let diskUsageKind = kinds.PRIMARY; + + if (diskUsage > 90) { + diskUsageKind = kinds.DANGER; + } else if (diskUsage > 80) { + diskUsageKind = kinds.WARNING; + } + + return ( + + + {item.path} + + { + item.label && + ` (${item.label})` + } + + + + {formatBytes(freeSpace)} + + + + {formatBytes(totalSpace)} + + + + + + + ); + }) + } + +
+ } +
+ ); + } + +} + +DiskSpace.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default DiskSpace; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js new file mode 100644 index 000000000..3049b2ead --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDiskSpace } from 'Store/Actions/systemActions'; +import DiskSpace from './DiskSpace'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.diskSpace, + (diskSpace) => { + const { + isFetching, + items + } = diskSpace; + + return { + isFetching, + items + }; + } + ); +} + +const mapDispatchToProps = { + fetchDiskSpace +}; + +class DiskSpaceConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDiskSpace(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DiskSpaceConnector.propTypes = { + fetchDiskSpace: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector); diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css new file mode 100644 index 000000000..1aad8ee77 --- /dev/null +++ b/frontend/src/System/Status/Health/Health.css @@ -0,0 +1,21 @@ +.legend { + display: flex; + justify-content: space-between; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 2px; + margin-left: 10px; + text-align: left; +} + +.status { + width: 20px; +} + +.healthOk { + margin-bottom: 25px; +} + diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js new file mode 100644 index 000000000..32fee59dc --- /dev/null +++ b/frontend/src/System/Status/Health/Health.js @@ -0,0 +1,206 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './Health.css'; + +function getInternalLink(source) { + switch (source) { + case 'IndexerRssCheck': + case 'IndexerSearchCheck': + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + case 'ImportMechanismCheck': + return ( + + ); + case 'RootFolderCheck': + return ( + + ); + case 'UpdateCheck': + return ( + + ); + default: + return; + } +} + +function getTestLink(source, props) { + switch (source) { + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + return ( + + ); + + default: + break; + } +} + +const columns = [ + { + className: styles.status, + name: 'type', + isVisible: true + }, + { + name: 'message', + label: 'Message', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class Health extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + items + } = this.props; + + const healthIssues = !!items.length; + + return ( +
+ Health + + { + isFetching && isPopulated && + + } +
+ } + > + { + isFetching && !isPopulated && + + } + + { + !healthIssues && +
+ No issues with your configuration +
+ } + + { + healthIssues && + + + { + items.map((item) => { + const internalLink = getInternalLink(item.source); + const testLink = getTestLink(item.source, this.props); + + return ( + + + + + + {item.message} + + + + + { + internalLink + } + + { + !!testLink && + testLink + } + + + ); + }) + } + +
+ } + + ); + } + +} + +Health.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + isTestingAllDownloadClients: PropTypes.bool.isRequired, + isTestingAllIndexers: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default Health; diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js new file mode 100644 index 000000000..d2adc41cc --- /dev/null +++ b/frontend/src/System/Status/Health/HealthConnector.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions'; +import Health from './Health'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.health, + (state) => state.settings.downloadClients.isTestingAll, + (state) => state.settings.indexers.isTestingAll, + (health, isTestingAllDownloadClients, isTestingAllIndexers) => { + const { + isFetching, + isPopulated, + items + } = health; + + return { + isFetching, + isPopulated, + items, + isTestingAllDownloadClients, + isTestingAllIndexers + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchHealth: fetchHealth, + dispatchTestAllDownloadClients: testAllDownloadClients, + dispatchTestAllIndexers: testAllIndexers +}; + +class HealthConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchHealth(); + } + + // + // Render + + render() { + const { + dispatchFetchHealth, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +HealthConnector.propTypes = { + dispatchFetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector); diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js new file mode 100644 index 000000000..181eae916 --- /dev/null +++ b/frontend/src/System/Status/Health/HealthStatusConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.system.health, + (app, health) => { + const count = health.items.length; + let errors = false; + let warnings = false; + + health.items.forEach((item) => { + if (item.type === 'error') { + errors = true; + } + + if (item.type === 'warning') { + warnings = true; + } + }); + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: health.isPopulated, + count, + errors, + warnings + }; + } + ); +} + +const mapDispatchToProps = { + fetchHealth +}; + +class HealthStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchHealth(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchHealth(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HealthStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector); diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js new file mode 100644 index 000000000..f338b3f1f --- /dev/null +++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js @@ -0,0 +1,52 @@ +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; + +class MoreInfo extends Component { + + // + // Render + + render() { + return ( +
+ + Home page + + radarr.video + + + Wiki + + github.com/Radarr/Radarr/wiki + + + Donations + + radarr.video/donate + + + Source + + github.com/Radarr/Radarr + + + Feature Requests + + github.com/Radarr/Radarr/issues + + + +
+ ); + } +} + +MoreInfo.propTypes = { + +}; + +export default MoreInfo; diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js new file mode 100644 index 000000000..f0b157515 --- /dev/null +++ b/frontend/src/System/Status/Status.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import HealthConnector from './Health/HealthConnector'; +import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector'; +import AboutConnector from './About/AboutConnector'; +import MoreInfo from './MoreInfo/MoreInfo'; + +class Status extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + ); + } + +} + +export default Status; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css new file mode 100644 index 000000000..30f86efff --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css @@ -0,0 +1,31 @@ +.trigger { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.triggerContent { + display: flex; + justify-content: space-between; + width: 100%; +} + +.queued, +.started, +.ended { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.duration { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js new file mode 100644 index 000000000..4aa6d76d6 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js @@ -0,0 +1,265 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './QueuedTaskRow.css'; + +function getStatusIconProps(status, message) { + const title = titleCase(status); + + switch (status) { + case 'queued': + return { + name: icons.PENDING, + title + }; + + case 'started': + return { + name: icons.REFRESH, + isSpinning: true, + title + }; + + case 'completed': + return { + name: icons.CHECK, + kind: kinds.SUCCESS, + title: message === 'Completed' ? title : `${title}: ${message}` + }; + + case 'failed': + return { + name: icons.FATAL, + kind: kinds.ERROR, + title: `${title}: ${message}` + }; + + default: + return { + name: icons.UNKNOWN, + title + }; + } +} + +function getFormattedDates(props) { + const { + queued, + started, + ended, + showRelativeDates, + shortDateFormat + } = props; + + if (showRelativeDates) { + return { + queuedAt: moment(queued).fromNow(), + startedAt: started ? moment(started).fromNow() : '-', + endedAt: ended ? moment(ended).fromNow() : '-' + }; + } + + return { + queuedAt: formatDate(queued, shortDateFormat), + startedAt: started ? formatDate(started, shortDateFormat) : '-', + endedAt: ended ? formatDate(ended, shortDateFormat) : '-' + }; +} + +class QueuedTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + ...getFormattedDates(props), + isCancelConfirmModalOpen: false + }; + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + queued, + started, + ended + } = this.props; + + if ( + queued !== prevProps.queued || + started !== prevProps.started || + ended !== prevProps.ended + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Control + + setUpdateTimer() { + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, 30000); + } + + // + // Listeners + + onCancelPress = () => { + this.setState({ + isCancelConfirmModalOpen: true + }); + } + + onAbortCancel = () => { + this.setState({ + isCancelConfirmModalOpen: false + }); + } + + // + // Render + + render() { + const { + trigger, + commandName, + queued, + started, + ended, + status, + duration, + message, + longDateFormat, + timeFormat, + onCancelPress + } = this.props; + + const { + queuedAt, + startedAt, + endedAt, + isCancelConfirmModalOpen + } = this.state; + + let triggerIcon = icons.UNKNOWN; + + if (trigger === 'manual') { + triggerIcon = icons.INTERACTIVE; + } else if (trigger === 'scheduled') { + triggerIcon = icons.SCHEDULED; + } + + return ( + + + + + + + + + + {commandName} + + + {queuedAt} + + + + {startedAt} + + + + {endedAt} + + + + {formatTimeSpan(duration)} + + + + { + status === 'queued' && + + } + + + + + ); + } +} + +QueuedTaskRow.propTypes = { + trigger: PropTypes.string.isRequired, + commandName: PropTypes.string.isRequired, + queued: PropTypes.string.isRequired, + started: PropTypes.string, + ended: PropTypes.string, + status: PropTypes.string.isRequired, + duration: PropTypes.string, + message: PropTypes.string, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onCancelPress: PropTypes.func.isRequired +}; + +export default QueuedTaskRow; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js new file mode 100644 index 000000000..f55ab985a --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueuedTaskRow from './QueuedTaskRow'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onCancelPress() { + dispatch(cancelCommand({ + id: props.id + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow); diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js new file mode 100644 index 000000000..a2fd526fa --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import QueuedTaskRowConnector from './QueuedTaskRowConnector'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true + }, + { + name: 'commandName', + label: 'Name', + isVisible: true + }, + { + name: 'queued', + label: 'Queued', + isVisible: true + }, + { + name: 'started', + label: 'Started', + isVisible: true + }, + { + name: 'ended', + label: 'Ended', + isVisible: true + }, + { + name: 'duration', + label: 'Duration', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function QueuedTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +QueuedTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js new file mode 100644 index 000000000..5fa4d9ead --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import QueuedTasks from './QueuedTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands +}; + +class QueuedTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchCommands(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueuedTasksConnector.propTypes = { + dispatchFetchCommands: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css new file mode 100644 index 000000000..dc83cfd69 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css @@ -0,0 +1,18 @@ +.interval { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.lastExecution, +.nextExecution { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js new file mode 100644 index 000000000..82cedc720 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js @@ -0,0 +1,182 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ScheduledTaskRow.css'; + +function getFormattedDates(props) { + const { + lastExecution, + nextExecution, + interval, + showRelativeDates, + shortDateFormat + } = props; + + const isDisabled = interval === 0; + + if (showRelativeDates) { + return { + lastExecutionTime: moment(lastExecution).fromNow(), + nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow() + }; + } + + return { + lastExecutionTime: formatDate(lastExecution, shortDateFormat), + nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat) + }; +} + +class ScheduledTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = getFormattedDates(props); + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + lastExecution, + nextExecution + } = this.props; + + if ( + lastExecution !== prevProps.lastExecution || + nextExecution !== prevProps.nextExecution + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Listeners + + setUpdateTimer() { + const { interval } = this.props; + const timeout = interval < 60 ? 10000 : 60000; + + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, timeout); + } + + // + // Render + + render() { + const { + name, + interval, + lastExecution, + nextExecution, + isQueued, + isExecuting, + longDateFormat, + timeFormat, + onExecutePress + } = this.props; + + const { + lastExecutionTime, + nextExecutionTime + } = this.state; + + const isDisabled = interval === 0; + const executeNow = !isDisabled && moment().isAfter(nextExecution); + const hasNextExecutionTime = !isDisabled && !executeNow; + const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); + + return ( + + {name} + + {isDisabled ? 'disabled' : duration} + + + + {lastExecutionTime} + + + { + isDisabled && + - + } + + { + executeNow && isQueued && + queued + } + + { + executeNow && !isQueued && + now + } + + { + hasNextExecutionTime && + + {nextExecutionTime} + + } + + + + + + ); + } +} + +ScheduledTaskRow.propTypes = { + name: PropTypes.string.isRequired, + interval: PropTypes.number.isRequired, + lastExecution: PropTypes.string.isRequired, + nextExecution: PropTypes.string.isRequired, + isQueued: PropTypes.bool.isRequired, + isExecuting: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onExecutePress: PropTypes.func.isRequired +}; + +export default ScheduledTaskRow; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js new file mode 100644 index 000000000..79a0c6c87 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchTask } from 'Store/Actions/systemActions'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import ScheduledTaskRow from './ScheduledTaskRow'; + +function createMapStateToProps() { + return createSelector( + (state, { taskName }) => taskName, + createCommandsSelector(), + createUISettingsSelector(), + (taskName, commands, uiSettings) => { + const command = findCommand(commands, { name: taskName }); + + return { + isQueued: !!(command && command.state === 'queued'), + isExecuting: isCommandExecuting(command), + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + const taskName = props.taskName; + + return { + dispatchFetchTask() { + dispatch(fetchTask({ + id: props.id + })); + }, + + onExecutePress() { + dispatch(executeCommand({ + name: taskName + })); + } + }; +} + +class ScheduledTaskRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + isExecuting, + dispatchFetchTask + } = this.props; + + if (!isExecuting && prevProps.isExecuting) { + // Give the host a moment to update after the command completes + setTimeout(() => { + dispatchFetchTask(); + }, 1000); + } + } + + // + // Render + + render() { + const { + dispatchFetchTask, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +ScheduledTaskRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isExecuting: PropTypes.bool.isRequired, + dispatchFetchTask: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js new file mode 100644 index 000000000..7c6fe8a32 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ScheduledTaskRowConnector from './ScheduledTaskRowConnector'; + +const columns = [ + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'interval', + label: 'Interval', + isVisible: true + }, + { + name: 'lastExecution', + label: 'Last Execution', + isVisible: true + }, + { + name: 'nextExecution', + label: 'Next Execution', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function ScheduledTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +ScheduledTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default ScheduledTasks; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js new file mode 100644 index 000000000..8f418d3bb --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTasks } from 'Store/Actions/systemActions'; +import ScheduledTasks from './ScheduledTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.tasks, + (tasks) => { + return tasks; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTasks: fetchTasks +}; + +class ScheduledTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchTasks(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ScheduledTasksConnector.propTypes = { + dispatchFetchTasks: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector); diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js new file mode 100644 index 000000000..dbbb4d1bf --- /dev/null +++ b/frontend/src/System/Tasks/Tasks.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; +import QueuedTasksConnector from './Queued/QueuedTasksConnector'; + +function Tasks() { + return ( + + + + + + + ); +} + +export default Tasks; diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css new file mode 100644 index 000000000..d21897373 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.css @@ -0,0 +1,4 @@ +.title { + margin-top: 10px; + font-size: 16px; +} diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js new file mode 100644 index 000000000..63c7e0d85 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './UpdateChanges.css'; + +class UpdateChanges extends Component { + + // + // Render + + render() { + const { + title, + changes + } = this.props; + + if (changes.length === 0) { + return null; + } + + return ( +
+
{title}
+
    + { + changes.map((change, index) => { + return ( +
  • + {change} +
  • + ); + }) + } +
+
+ ); + } + +} + +UpdateChanges.propTypes = { + title: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.string) +}; + +export default UpdateChanges; diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css new file mode 100644 index 000000000..3502f6d1f --- /dev/null +++ b/frontend/src/System/Updates/Updates.css @@ -0,0 +1,57 @@ +.updateAvailable { + display: flex; +} + +.upToDate { + display: flex; + margin-bottom: 20px; +} + +.upToDateIcon { + color: #37bc9b; + font-size: 30px; +} + +.upToDateMessage { + padding-left: 5px; + font-size: 18px; + line-height: 30px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-left: auto; +} + +.update { + margin-top: 20px; +} + +.info { + display: flex; + align-items: center; + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; +} + +.version { + font-size: 21px; +} + +.space { + padding: 0 5px; +} + +.date { + font-size: 16px; +} + +.branch { + composes: label from 'Components/Label.css'; + + margin-left: 10px; + font-size: 14px; +} diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js new file mode 100644 index 000000000..215d1aa5b --- /dev/null +++ b/frontend/src/System/Updates/Updates.js @@ -0,0 +1,169 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import formatDate from 'Utilities/Date/formatDate'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import UpdateChanges from './UpdateChanges'; +import styles from './Updates.css'; + +class Updates extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + shortDateFormat, + onInstallLatestPress + } = this.props; + + const hasUpdates = isPopulated && !error && items.length > 0; + const noUpdates = isPopulated && !error && !items.length; + const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true }); + const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; + + return ( + + + { + !isPopulated && !error && + + } + + { + noUpdates && +
No updates are available
+ } + + { + hasUpdateToInstall && +
+ + Install Latest + + + { + isFetching && + + } +
+ } + + { + noUpdateToInstall && +
+ +
+ The latest version of Radarr is already installed +
+ + { + isFetching && + + } +
+ } + + { + hasUpdates && +
+ { + items.map((update) => { + const hasChanges = !!update.changes; + + return ( +
+
+
{update.version}
+
+
{formatDate(update.releaseDate, shortDateFormat)}
+ + { + update.branch !== 'master' && + + } +
+ + { + !hasChanges && +
Maintenance release
+ } + + { + hasChanges && +
+ + + +
+ } +
+ ); + }) + } +
+ } + + { + !!error && +
+ Failed to fetch updates +
+ } +
+
+ ); + } + +} + +Updates.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + isInstallingUpdate: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + onInstallLatestPress: PropTypes.func.isRequired +}; + +export default Updates; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js new file mode 100644 index 000000000..0d0aa491f --- /dev/null +++ b/frontend/src/System/Updates/UpdatesConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import * as commandNames from 'Commands/commandNames'; +import Updates from './Updates'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.updates, + createUISettingsSelector(), + createCommandExecutingSelector(commandNames.APPLICATION_UPDATE), + (updates, uiSettings, isInstallingUpdate) => { + const { + isFetching, + isPopulated, + error, + items + } = updates; + + return { + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + shortDateFormat: uiSettings.shortDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdates, + executeCommand +}; + +class UpdatesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdates(); + } + + // + // Listeners + + onInstallLatestPress = () => { + this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdatesConnector.propTypes = { + fetchUpdates: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector); diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js new file mode 100644 index 000000000..165bb5cc1 --- /dev/null +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -0,0 +1,13 @@ +import _ from 'lodash'; + +export default function getIndexOfFirstCharacter(items, character) { + return _.findIndex(items, (item) => { + const firstCharacter = item.sortTitle.charAt(0); + + if (character === '#') { + return !isNaN(firstCharacter); + } + + return firstCharacter === character; + }); +} diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js new file mode 100644 index 000000000..1956d3bac --- /dev/null +++ b/frontend/src/Utilities/Array/sortByName.js @@ -0,0 +1,5 @@ +function sortByName(a, b) { + return a.name.localeCompare(b.name); +} + +export default sortByName; diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js new file mode 100644 index 000000000..cf7d5444a --- /dev/null +++ b/frontend/src/Utilities/Command/findCommand.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +import isSameCommand from './isSameCommand'; + +function findCommand(commands, options) { + return _.findLast(commands, (command) => { + return isSameCommand(command.body, options); + }); +} + +export default findCommand; diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js new file mode 100644 index 000000000..66043bf03 --- /dev/null +++ b/frontend/src/Utilities/Command/index.js @@ -0,0 +1,5 @@ +export { default as findCommand } from './findCommand'; +export { default as isCommandComplete } from './isCommandComplete'; +export { default as isCommandExecuting } from './isCommandExecuting'; +export { default as isCommandFailed } from './isCommandFailed'; +export { default as isSameCommand } from './isSameCommand'; diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js new file mode 100644 index 000000000..558ab801b --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandComplete.js @@ -0,0 +1,9 @@ +function isCommandComplete(command) { + if (!command) { + return false; + } + + return command.status === 'complete'; +} + +export default isCommandComplete; diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js new file mode 100644 index 000000000..8e637704e --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandExecuting.js @@ -0,0 +1,9 @@ +function isCommandExecuting(command) { + if (!command) { + return false; + } + + return command.status === 'queued' || command.status === 'started'; +} + +export default isCommandExecuting; diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js new file mode 100644 index 000000000..00e5ccdf2 --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandFailed.js @@ -0,0 +1,12 @@ +function isCommandFailed(command) { + if (!command) { + return false; + } + + return command.status === 'failed' || + command.status === 'aborted' || + command.status === 'cancelled' || + command.status === 'orphaned'; +} + +export default isCommandFailed; diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js new file mode 100644 index 000000000..d0acb24b5 --- /dev/null +++ b/frontend/src/Utilities/Command/isSameCommand.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; + +function isSameCommand(commandA, commandB) { + if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) { + return false; + } + + for (const key in commandB) { + if (key !== 'name') { + const value = commandB[key]; + if (Array.isArray(value)) { + if (_.difference(value, commandA[key]).length > 0) { + return false; + } + } else if (value !== commandA[key]) { + return false; + } + } + } + + return true; +} + +export default isSameCommand; diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js new file mode 100644 index 000000000..9285b10fe --- /dev/null +++ b/frontend/src/Utilities/Constants/keyCodes.js @@ -0,0 +1,7 @@ +export const TAB = 9; +export const ENTER = 13; +export const SHIFT = 16; +export const CONTROL = 17; +export const ESCAPE = 27; +export const UP_ARROW = 38; +export const DOWN_ARROW = 40; diff --git a/frontend/src/Utilities/Date/dateFilterPredicate.js b/frontend/src/Utilities/Date/dateFilterPredicate.js new file mode 100644 index 000000000..2c74f435a --- /dev/null +++ b/frontend/src/Utilities/Date/dateFilterPredicate.js @@ -0,0 +1,33 @@ +import moment from 'moment'; +import isAfter from 'Utilities/Date/isAfter'; +import isBefore from 'Utilities/Date/isBefore'; +import * as filterTypes from 'Helpers/Props/filterTypes'; + +export default function(itemValue, filterValue, type) { + if (!itemValue) { + return false; + } + + switch (type) { + case filterTypes.LESS_THAN: + return moment(itemValue).isBefore(filterValue); + + case filterTypes.GREATER_THAN: + return moment(itemValue).isAfter(filterValue); + + case filterTypes.IN_LAST: + return ( + isAfter(itemValue, { [filterValue.time]: filterValue.value * -1 }) && + isBefore(itemValue) + ); + + case filterTypes.IN_NEXT: + return ( + isAfter(itemValue) && + isBefore(itemValue, { [filterValue.time]: filterValue.value }) + ); + + default: + return false; + } +} diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js new file mode 100644 index 000000000..92eb57840 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDate.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function formatDate(date, dateFormat) { + if (!date) { + return ''; + } + + return moment(date).format(dateFormat); +} + +export default formatDate; diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js new file mode 100644 index 000000000..f36f4f3e0 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDateTime.js @@ -0,0 +1,39 @@ +import moment from 'moment'; +import formatTime from './formatTime'; +import isToday from './isToday'; +import isTomorrow from './isTomorrow'; +import isYesterday from './isYesterday'; + +function getRelativeDay(date, includeRelativeDate) { + if (!includeRelativeDate) { + return ''; + } + + if (isYesterday(date)) { + return 'Yesterday, '; + } + + if (isToday(date)) { + return 'Today, '; + } + + if (isTomorrow(date)) { + return 'Tomorrow, '; + } + + return ''; +} + +function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) { + if (!date) { + return ''; + } + + const relativeDay = getRelativeDay(date, includeRelativeDay); + const formattedDate = moment(date).format(dateFormat); + const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + + return `${relativeDay}${formattedDate} ${formattedTime}`; +} + +export default formatDateTime; diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js new file mode 100644 index 000000000..89c908d1f --- /dev/null +++ b/frontend/src/Utilities/Date/formatTime.js @@ -0,0 +1,19 @@ +import moment from 'moment'; + +function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) { + if (!date) { + return ''; + } + + if (includeSeconds) { + timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss'); + } else if (includeMinuteZero) { + timeFormat = timeFormat.replace('(:mm)', ':mm'); + } else { + timeFormat = timeFormat.replace('(:mm)', ''); + } + + return moment(date).format(timeFormat); +} + +export default formatTime; diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js new file mode 100644 index 000000000..ef1a278e5 --- /dev/null +++ b/frontend/src/Utilities/Date/formatTimeSpan.js @@ -0,0 +1,24 @@ +import moment from 'moment'; +import padNumber from 'Utilities/Number/padNumber'; + +function formatTimeSpan(timeSpan) { + if (!timeSpan) { + return ''; + } + + const duration = moment.duration(timeSpan); + const days = duration.get('days'); + const hours = padNumber(duration.get('hours'), 2); + const minutes = padNumber(duration.get('minutes'), 2); + const seconds = padNumber(duration.get('seconds'), 2); + + const time = `${hours}:${minutes}:${seconds}`; + + if (days > 0) { + return `${days}d ${time}`; + } + + return time; +} + +export default formatTimeSpan; diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.js new file mode 100644 index 000000000..0a60135ce --- /dev/null +++ b/frontend/src/Utilities/Date/getRelativeDate.js @@ -0,0 +1,42 @@ +import moment from 'moment'; +import formatTime from 'Utilities/Date/formatTime'; +import isInNextWeek from 'Utilities/Date/isInNextWeek'; +import isToday from 'Utilities/Date/isToday'; +import isTomorrow from 'Utilities/Date/isTomorrow'; +import isYesterday from 'Utilities/Date/isYesterday'; + +function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) { + if (!date) { + return null; + } + + const isTodayDate = isToday(date); + + if (isTodayDate && timeForToday && timeFormat) { + return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + } + + if (!showRelativeDates) { + return moment(date).format(shortDateFormat); + } + + if (isYesterday(date)) { + return 'Yesterday'; + } + + if (isTodayDate) { + return 'Today'; + } + + if (isTomorrow(date)) { + return 'Tomorrow'; + } + + if (isInNextWeek(date)) { + return moment(date).format('dddd'); + } + + return moment(date).format(shortDateFormat); +} + +export default getRelativeDate; diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js new file mode 100644 index 000000000..4bbd8660b --- /dev/null +++ b/frontend/src/Utilities/Date/isAfter.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isAfter(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isAfter(offsetTime); +} + +export default isAfter; diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js new file mode 100644 index 000000000..3e1e81f67 --- /dev/null +++ b/frontend/src/Utilities/Date/isBefore.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isBefore(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isBefore(offsetTime); +} + +export default isBefore; diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js new file mode 100644 index 000000000..7b5fd7cc7 --- /dev/null +++ b/frontend/src/Utilities/Date/isInNextWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isInNextWeek(date) { + if (!date) { + return false; + } + const now = moment(); + return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day')); +} + +export default isInNextWeek; diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js new file mode 100644 index 000000000..14b76ffb7 --- /dev/null +++ b/frontend/src/Utilities/Date/isSameWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isSameWeek(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'week'); +} + +export default isSameWeek; diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js new file mode 100644 index 000000000..31502951f --- /dev/null +++ b/frontend/src/Utilities/Date/isToday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isToday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'day'); +} + +export default isToday; diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js new file mode 100644 index 000000000..d22386dbd --- /dev/null +++ b/frontend/src/Utilities/Date/isTomorrow.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isTomorrow(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().add(1, 'day'), 'day'); +} + +export default isTomorrow; diff --git a/frontend/src/Utilities/Date/isYesterday.js b/frontend/src/Utilities/Date/isYesterday.js new file mode 100644 index 000000000..9de21d82a --- /dev/null +++ b/frontend/src/Utilities/Date/isYesterday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isYesterday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().subtract(1, 'day'), 'day'); +} + +export default isYesterday; diff --git a/frontend/src/Utilities/Episode/updateEpisodes.js b/frontend/src/Utilities/Episode/updateEpisodes.js new file mode 100644 index 000000000..80890b53f --- /dev/null +++ b/frontend/src/Utilities/Episode/updateEpisodes.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { update } from 'Store/Actions/baseActions'; + +function updateEpisodes(section, episodes, episodeIds, options) { + const data = _.reduce(episodes, (result, item) => { + if (episodeIds.indexOf(item.id) > -1) { + result.push({ + ...item, + ...options + }); + } else { + result.push(item); + } + + return result; + }, []); + + return update({ section, data }); +} + +export default updateEpisodes; diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js new file mode 100644 index 000000000..1c104073c --- /dev/null +++ b/frontend/src/Utilities/Filter/findSelectedFilters.js @@ -0,0 +1,19 @@ +export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) { + if (!selectedFilterKey) { + return []; + } + + let selectedFilter = filters.find((f) => f.key === selectedFilterKey); + + if (!selectedFilter) { + selectedFilter = customFilters.find((f) => f.id === selectedFilterKey); + } + + if (!selectedFilter) { + // TODO: throw in dev + console.error('Matching filter not found'); + return []; + } + + return selectedFilter.filters; +} diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js new file mode 100644 index 000000000..70b0b51f1 --- /dev/null +++ b/frontend/src/Utilities/Filter/getFilterValue.js @@ -0,0 +1,11 @@ +export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) { + const filter = filters.find((f) => f.key === filterKey); + + if (!filter) { + return defaultValue; + } + + const filterValue = filter.filters.find((f) => f.key === filterValueKey); + + return filterValue ? filterValue.value : defaultValue; +} diff --git a/frontend/src/Utilities/Movie/getNewMovie.js b/frontend/src/Utilities/Movie/getNewMovie.js new file mode 100644 index 000000000..1b4fa012e --- /dev/null +++ b/frontend/src/Utilities/Movie/getNewMovie.js @@ -0,0 +1,18 @@ + +function getNewMovie(movie, payload) { + const { + rootFolderPath, + monitor, + qualityProfileId, + tags + } = payload; + + movie.monitored = monitor === 'true'; + movie.qualityProfileId = qualityProfileId; + movie.rootFolderPath = rootFolderPath; + movie.tags = tags; + + return movie; +} + +export default getNewMovie; diff --git a/frontend/src/Utilities/Movie/getProgressBarKind.js b/frontend/src/Utilities/Movie/getProgressBarKind.js new file mode 100644 index 000000000..cc270f5e8 --- /dev/null +++ b/frontend/src/Utilities/Movie/getProgressBarKind.js @@ -0,0 +1,23 @@ +import { kinds } from 'Helpers/Props'; + +function getProgressBarKind(status, monitored, hasFile) { + if (status === 'announced') { + return kinds.PRIMARY; + } + + if (hasFile && monitored) { + return kinds.SUCCESS; + } + + if (hasFile && !monitored) { + return kinds.DEFAULT; + } + + if (monitored) { + return kinds.DANGER; + } + + return kinds.WARNING; +} + +export default getProgressBarKind; diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js new file mode 100644 index 000000000..6c63fb117 --- /dev/null +++ b/frontend/src/Utilities/Number/convertToBytes.js @@ -0,0 +1,16 @@ + +function convertToBytes(input, power, binaryPrefix) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + const prefix = binaryPrefix ? 1024 : 1000; + const multiplier = Math.pow(prefix, power); + const result = size * multiplier; + + return Math.round(result); +} + +export default convertToBytes; diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js new file mode 100644 index 000000000..b8a4aacc5 --- /dev/null +++ b/frontend/src/Utilities/Number/formatAge.js @@ -0,0 +1,17 @@ +function formatAge(age, ageHours, ageMinutes) { + age = Math.round(age); + ageHours = parseFloat(ageHours); + ageMinutes = ageMinutes && parseFloat(ageMinutes); + + if (age < 2 && ageHours) { + if (ageHours < 2 && !!ageMinutes) { + return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`; + } + + return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`; + } + + return `${age} ${age === 1 ? 'day' : 'days'}`; +} + +export default formatAge; diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js new file mode 100644 index 000000000..1ff1b5a97 --- /dev/null +++ b/frontend/src/Utilities/Number/formatBytes.js @@ -0,0 +1,16 @@ +import filesize from 'filesize'; + +function formatBytes(input) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + return filesize(size, { + base: 2, + round: 1 + }); +} + +export default formatBytes; diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js new file mode 100644 index 000000000..53ae69cac --- /dev/null +++ b/frontend/src/Utilities/Number/padNumber.js @@ -0,0 +1,10 @@ +function padNumber(input, width, paddingCharacter = 0) { + if (input == null) { + return ''; + } + + input = `${input}`; + return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input; +} + +export default padNumber; diff --git a/frontend/src/Utilities/Number/roundNumber.js b/frontend/src/Utilities/Number/roundNumber.js new file mode 100644 index 000000000..e1a19018f --- /dev/null +++ b/frontend/src/Utilities/Number/roundNumber.js @@ -0,0 +1,5 @@ +export default function roundNumber(input, decimalPlaces = 1) { + const multiplier = Math.pow(10, decimalPlaces); + + return Math.round(input * multiplier) / multiplier; +} diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js new file mode 100644 index 000000000..1ba874660 --- /dev/null +++ b/frontend/src/Utilities/Object/getErrorMessage.js @@ -0,0 +1,11 @@ +function getErrorMessage(xhr, fallbackErrorMessage) { + if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) { + return fallbackErrorMessage; + } + + const message = xhr.responseJSON.message; + + return message || fallbackErrorMessage; +} + +export default getErrorMessage; diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js new file mode 100644 index 000000000..f89c99a10 --- /dev/null +++ b/frontend/src/Utilities/Object/hasDifferentItems.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; + +function hasDifferentItems(prevItems, currentItems, idProp = 'id') { + const diff1 = _.differenceBy(prevItems, currentItems, (item) => item[idProp]); + const diff2 = _.differenceBy(currentItems, prevItems, (item) => item[idProp]); + + return diff1.length > 0 || diff2.length > 0; +} + +export default hasDifferentItems; diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js new file mode 100644 index 000000000..c2c0c17e3 --- /dev/null +++ b/frontend/src/Utilities/Object/selectUniqueIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function selectUniqueIds(items, idProp) { + const ids = _.reduce(items, (result, item) => { + if (item[idProp]) { + result.push(item[idProp]); + } + + return result; + }, []); + + return _.uniq(ids); +} + +export default selectUniqueIds; diff --git a/frontend/src/Utilities/Quality/getQualities.js b/frontend/src/Utilities/Quality/getQualities.js new file mode 100644 index 000000000..da09851ea --- /dev/null +++ b/frontend/src/Utilities/Quality/getQualities.js @@ -0,0 +1,16 @@ +export default function getQualities(qualities) { + if (!qualities) { + return []; + } + + return qualities.reduce((acc, item) => { + if (item.quality) { + acc.push(item.quality); + } else { + const groupQualities = item.items.map((i) => i.quality); + acc.push(...groupQualities); + } + + return acc; + }, []); +} diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js new file mode 100644 index 000000000..358448ca9 --- /dev/null +++ b/frontend/src/Utilities/ResolutionUtility.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; + +module.exports = { + resolutions: { + desktopLarge: 1200, + desktop: 992, + tablet: 768, + mobile: 480 + }, + + isDesktopLarge() { + return $(window).width() < this.resolutions.desktopLarge; + }, + + isDesktop() { + return $(window).width() < this.resolutions.desktop; + }, + + isTablet() { + return $(window).width() < this.resolutions.tablet; + }, + + isMobile() { + return $(window).width() < this.resolutions.mobile; + } +}; diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js new file mode 100644 index 000000000..60923a646 --- /dev/null +++ b/frontend/src/Utilities/State/getProviderState.js @@ -0,0 +1,42 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; + +function getProviderState(payload, getState, section) { + const { + id, + ...otherPayload + } = payload; + + const state = getSectionState(getState(), section, true); + const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload); + const pendingFields = state.pendingChanges.fields || {}; + delete pendingChanges.fields; + + const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {}; + + if (item.fields) { + pendingChanges.fields = _.reduce(item.fields, (result, field) => { + const name = field.name; + + const value = pendingFields.hasOwnProperty(name) ? + pendingFields[name] : + field.value; + + // Only send the name and value to the server + result.push({ + name, + value + }); + + return result; + }, []); + } + + const result = Object.assign({}, item, pendingChanges); + + delete result.presets; + + return result; +} + +export default getProviderState; diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js new file mode 100644 index 000000000..00871bed2 --- /dev/null +++ b/frontend/src/Utilities/State/getSectionState.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; + +function getSectionState(state, section, isFullStateTree = false) { + if (isFullStateTree) { + return _.get(state, section); + } + + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state[subSection]); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state[section]); + } + + return Object.assign({}, state); +} + +export default getSectionState; diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js new file mode 100644 index 000000000..c8a31760c --- /dev/null +++ b/frontend/src/Utilities/State/selectProviderSchema.js @@ -0,0 +1,34 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function applySchemaDefaults(selectedSchema, schemaDefaults) { + if (!schemaDefaults) { + return selectedSchema; + } else if (_.isFunction(schemaDefaults)) { + return schemaDefaults(selectedSchema); + } + + return Object.assign(selectedSchema, schemaDefaults); +} + +function selectProviderSchema(state, section, payload, schemaDefaults) { + const newState = getSectionState(state, section); + + const { + implementation, + presetName + } = payload; + + const selectedImplementation = _.find(newState.schema, { implementation }); + + const selectedSchema = presetName ? + _.find(selectedImplementation.presets, { name: presetName }) : + selectedImplementation; + + newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults); + + return updateSectionState(state, section, newState); +} + +export default selectProviderSchema; diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js new file mode 100644 index 000000000..81b33ecaf --- /dev/null +++ b/frontend/src/Utilities/State/updateSectionState.js @@ -0,0 +1,16 @@ +function updateSectionState(state, section, newState) { + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state, { [subSection]: newState }); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state, { [section]: newState }); + } + + return Object.assign({}, state, newState); +} + +export default updateSectionState; diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js new file mode 100644 index 000000000..9e4e9abe8 --- /dev/null +++ b/frontend/src/Utilities/String/combinePath.js @@ -0,0 +1,5 @@ +export default function combinePath(isWindows, basePath, paths = []) { + const slash = isWindows ? '\\' : '/'; + + return `${basePath}${slash}${paths.join(slash)}`; +} diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js new file mode 100644 index 000000000..51b15ec60 --- /dev/null +++ b/frontend/src/Utilities/String/generateUUIDv4.js @@ -0,0 +1,6 @@ +export default function generateUUIDv4() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) => + // eslint-disable-next-line no-bitwise + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} diff --git a/frontend/src/Utilities/String/isString.js b/frontend/src/Utilities/String/isString.js new file mode 100644 index 000000000..1e7c3dff8 --- /dev/null +++ b/frontend/src/Utilities/String/isString.js @@ -0,0 +1,3 @@ +export default function isString(possibleString) { + return typeof possibleString === 'string' || possibleString instanceof String; +} diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js new file mode 100644 index 000000000..93341f85f --- /dev/null +++ b/frontend/src/Utilities/String/parseUrl.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import qs from 'qs'; + +// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils +const anchor = document.createElement('a'); + +export default function parseUrl(url) { + anchor.href = url; + + // The `origin`, `password`, and `username` properties are unavailable in + // Opera Presto. We synthesize `origin` if it's not present. While `password` + // and `username` are ignored intentionally. + const properties = _.pick( + anchor, + 'hash', + 'host', + 'hostname', + 'href', + 'origin', + 'pathname', + 'port', + 'protocol', + 'search' + ); + + properties.isAbsolute = (/^[\w:]*\/\//).test(url); + + if (properties.search) { + // Remove leading ? from querystring before parsing. + properties.params = qs.parse(properties.search.substring(1)); + } else { + properties.params = {}; + } + + return properties; +} diff --git a/frontend/src/Utilities/String/split.js b/frontend/src/Utilities/String/split.js new file mode 100644 index 000000000..0e57e7545 --- /dev/null +++ b/frontend/src/Utilities/String/split.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function split(input, separator = ',') { + if (!input) { + return []; + } + + return _.reduce(input.split(separator), (result, s) => { + if (s) { + result.push(s); + } + + return result; + }, []); +} + +export default split; diff --git a/frontend/src/Utilities/String/titleCase.js b/frontend/src/Utilities/String/titleCase.js new file mode 100644 index 000000000..5b76c10dd --- /dev/null +++ b/frontend/src/Utilities/String/titleCase.js @@ -0,0 +1,11 @@ +function titleCase(input) { + if (!input) { + return ''; + } + + return input.replace(/\b\w+/g, (match) => { + return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase(); + }); +} + +export default titleCase; diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js new file mode 100644 index 000000000..26102f89b --- /dev/null +++ b/frontend/src/Utilities/Table/areAllSelected.js @@ -0,0 +1,17 @@ +export default function areAllSelected(selectedState) { + let allSelected = true; + let allUnselected = true; + + Object.keys(selectedState).forEach((key) => { + if (selectedState[key]) { + allUnselected = false; + } else { + allSelected = false; + } + }); + + return { + allSelected, + allUnselected + }; +} diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js new file mode 100644 index 000000000..705f13a5d --- /dev/null +++ b/frontend/src/Utilities/Table/getSelectedIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function getSelectedIds(selectedState, { parseIds = true } = {}) { + return _.reduce(selectedState, (result, value, id) => { + if (value) { + const parsedId = parseIds ? parseInt(id) : id; + + result.push(parsedId); + } + + return result; + }, []); +} + +export default getSelectedIds; diff --git a/frontend/src/Utilities/Table/getToggledRange.js b/frontend/src/Utilities/Table/getToggledRange.js new file mode 100644 index 000000000..c0cc44fe5 --- /dev/null +++ b/frontend/src/Utilities/Table/getToggledRange.js @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +function getToggledRange(items, id, lastToggled) { + const lastToggledIndex = _.findIndex(items, { id: lastToggled }); + const changedIndex = _.findIndex(items, { id }); + let lower = 0; + let upper = 0; + + if (lastToggledIndex > changedIndex) { + lower = changedIndex; + upper = lastToggledIndex + 1; + } else { + lower = lastToggledIndex; + upper = changedIndex; + } + + return { + lower, + upper + }; +} + +export default getToggledRange; diff --git a/frontend/src/Utilities/Table/removeOldSelectedState.js b/frontend/src/Utilities/Table/removeOldSelectedState.js new file mode 100644 index 000000000..ff3a4fe11 --- /dev/null +++ b/frontend/src/Utilities/Table/removeOldSelectedState.js @@ -0,0 +1,16 @@ +import areAllSelected from './areAllSelected'; + +export default function removeOldSelectedState(state, prevItems) { + const selectedState = { + ...state.selectedState + }; + + prevItems.forEach((item) => { + delete selectedState[item.id]; + }); + + return { + ...areAllSelected(selectedState), + selectedState + }; +} diff --git a/frontend/src/Utilities/Table/selectAll.js b/frontend/src/Utilities/Table/selectAll.js new file mode 100644 index 000000000..ffaaeaddf --- /dev/null +++ b/frontend/src/Utilities/Table/selectAll.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function selectAll(selectedState, selected) { + const newSelectedState = _.reduce(Object.keys(selectedState), (result, item) => { + result[item] = selected; + return result; + }, {}); + + return { + allSelected: selected, + allUnselected: !selected, + lastToggled: null, + selectedState: newSelectedState + }; +} + +export default selectAll; diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js new file mode 100644 index 000000000..dbc0d6223 --- /dev/null +++ b/frontend/src/Utilities/Table/toggleSelected.js @@ -0,0 +1,30 @@ +import areAllSelected from './areAllSelected'; +import getToggledRange from './getToggledRange'; + +function toggleSelected(state, items, id, selected, shiftKey) { + const lastToggled = state.lastToggled; + const selectedState = { + ...state.selectedState, + [id]: selected + }; + + if (selected == null) { + delete selectedState[id]; + } + + if (shiftKey && lastToggled) { + const { lower, upper } = getToggledRange(items, id, lastToggled); + + for (let i = lower; i < upper; i++) { + selectedState[items[i].id] = selected; + } + } + + return { + ...areAllSelected(selectedState), + lastToggled: id, + selectedState + }; +} + +export default toggleSelected; diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js new file mode 100644 index 000000000..7ad8961da --- /dev/null +++ b/frontend/src/Utilities/createAjaxRequest.js @@ -0,0 +1,30 @@ +import $ from 'jquery'; + +export default function createAjaxRequest(ajaxOptions) { + const requestXHR = new window.XMLHttpRequest(); + let aborted = false; + let complete = false; + + function abortRequest() { + if (!complete) { + aborted = true; + requestXHR.abort(); + } + } + + const request = $.ajax({ + xhr: () => requestXHR, + ...ajaxOptions + }).then(null, (xhr, textStatus, errorThrown) => { + xhr.aborted = aborted; + + return $.Deferred().reject(xhr, textStatus, errorThrown).promise(); + }).always(() => { + complete = true; + }); + + return { + request, + abortRequest + }; +} diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js new file mode 100644 index 000000000..29bbf7ac4 --- /dev/null +++ b/frontend/src/Utilities/getPathWithUrlBase.js @@ -0,0 +1,3 @@ +export default function getPathWithUrlBase(path) { + return `${window.Radarr.urlBase}${path}`; +} diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js new file mode 100644 index 000000000..dae5150b7 --- /dev/null +++ b/frontend/src/Utilities/getUniqueElementId.js @@ -0,0 +1,7 @@ +let i = 0; + +// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022) + +export default function getUniqueElementId() { + return `id-${i++}`; +} diff --git a/frontend/src/Utilities/isMobile.js b/frontend/src/Utilities/isMobile.js new file mode 100644 index 000000000..489020a23 --- /dev/null +++ b/frontend/src/Utilities/isMobile.js @@ -0,0 +1,7 @@ +import MobileDetect from 'mobile-detect'; + +export default function isMobile() { + const mobileDetect = new MobileDetect(window.navigator.userAgent); + + return mobileDetect.mobile() != null; +} diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js new file mode 100644 index 000000000..f58dbe803 --- /dev/null +++ b/frontend/src/Utilities/pagePopulator.js @@ -0,0 +1,28 @@ +let currentPopulator = null; +let currentReasons = []; + +export function registerPagePopulator(populator, reasons = []) { + currentPopulator = populator; + currentReasons = reasons; +} + +export function unregisterPagePopulator(populator) { + if (currentPopulator === populator) { + currentPopulator = null; + currentReasons = []; + } +} + +export function repopulatePage(reason) { + if (!currentPopulator) { + return; + } + + if (!reason) { + currentPopulator(); + } + + if (reason && currentReasons.includes(reason)) { + currentPopulator(); + } +} diff --git a/frontend/src/Utilities/pages.js b/frontend/src/Utilities/pages.js new file mode 100644 index 000000000..1355442d9 --- /dev/null +++ b/frontend/src/Utilities/pages.js @@ -0,0 +1,9 @@ +const pages = { + FIRST: 'first', + PREVIOUS: 'previous', + NEXT: 'next', + LAST: 'last', + EXACT: 'exact' +}; + +export default pages; diff --git a/frontend/src/Utilities/requestAction.js b/frontend/src/Utilities/requestAction.js new file mode 100644 index 000000000..3f2564a7b --- /dev/null +++ b/frontend/src/Utilities/requestAction.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; +import _ from 'lodash'; + +function flattenProviderData(providerData) { + return _.reduce(Object.keys(providerData), (result, key) => { + const property = providerData[key]; + + if (key === 'fields') { + result[key] = property; + } else { + result[key] = property.value; + } + + return result; + }, {}); +} + +function requestAction(payload) { + const { + provider, + action, + providerData, + queryParams + } = payload; + + const ajaxOptions = { + url: `/${provider}/action/${action}`, + contentType: 'application/json', + method: 'POST', + data: JSON.stringify(flattenProviderData(providerData)) + }; + + if (queryParams) { + ajaxOptions.url += `?${$.param(queryParams, true)}`; + } + + return $.ajax(ajaxOptions); +} + +export default requestAction; diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js new file mode 100644 index 000000000..5479b32b9 --- /dev/null +++ b/frontend/src/Utilities/sectionTypes.js @@ -0,0 +1,6 @@ +const sectionTypes = { + COLLECTION: 'collection', + MODEL: 'model' +}; + +export default sectionTypes; diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js new file mode 100644 index 000000000..03fa39c00 --- /dev/null +++ b/frontend/src/Utilities/serverSideCollectionHandlers.js @@ -0,0 +1,12 @@ +const serverSideCollectionHandlers = { + FETCH: 'fetch', + FIRST_PAGE: 'firstPage', + PREVIOUS_PAGE: 'previousPage', + NEXT_PAGE: 'nextPage', + LAST_PAGE: 'lastPage', + EXACT_PAGE: 'exactPage', + SORT: 'sort', + FILTER: 'filter' +}; + +export default serverSideCollectionHandlers; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 000000000..04e0e11ef --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,15 @@ +html, +body { + height: 100%; /* needed for proper layout */ +} + +body { + overflow: hidden; + background-color: #f5f7fa; +} + +@media only screen and (max-width: $breakpointSmall) { + body { + overflow-y: auto; + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 000000000..c16e2852f --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Radarr + + + + + + + +
+ + + + + + + + + diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 000000000..396a7971c --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'react-dom'; +import createHistory from 'history/createBrowserHistory'; +import createAppStore from 'Store/createAppStore'; +import App from './App/App'; +import 'Styles/globals.css'; +import './index.css'; + +const history = createHistory(); +const store = createAppStore(history); + +render( + , + document.getElementById('root') +); diff --git a/frontend/src/jQuery/jquery.ajax.js b/frontend/src/jQuery/jquery.ajax.js new file mode 100644 index 000000000..299bccb64 --- /dev/null +++ b/frontend/src/jQuery/jquery.ajax.js @@ -0,0 +1,47 @@ +import $ from 'jquery'; + +const absUrlRegex = /^(https?:)?\/\//i; +const apiRoot = window.Radarr.apiRoot; +const urlBase = window.Radarr.urlBase; + +function isRelative(xhr) { + return !absUrlRegex.test(xhr.url); +} + +function moveBodyToQuery(xhr) { + if (xhr.data && xhr.type === 'DELETE') { + if (xhr.url.contains('?')) { + xhr.url += '&'; + } else { + xhr.url += '?'; + } + xhr.url += $.param(xhr.data); + delete xhr.data; + } +} + +function addRootUrl(xhr) { + const url = xhr.url; + if (url.startsWith('/signalr')) { + xhr.url = urlBase + xhr.url; + } else { + xhr.url = apiRoot + xhr.url; + } +} + +function addApiKey(xhr) { + xhr.headers = xhr.headers || {}; + xhr.headers['X-Api-Key'] = window.Radarr.apiKey; +} + +export default function() { + const originalAjax = $.ajax; + $.ajax = function(xhr) { + if (xhr && isRelative(xhr)) { + moveBodyToQuery(xhr); + addRootUrl(xhr); + addApiKey(xhr); + } + return originalAjax.apply(this, arguments); + }; +} diff --git a/frontend/src/login.html b/frontend/src/login.html new file mode 100644 index 000000000..216ab3910 --- /dev/null +++ b/frontend/src/login.html @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Login - Radarr + + + + + +
+
+
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+ + + + + + Forgot your password? +
+ + + +
+
+
+ + +
+
+ + + + diff --git a/frontend/src/oauth.html b/frontend/src/oauth.html new file mode 100644 index 000000000..16a34dbf3 --- /dev/null +++ b/frontend/src/oauth.html @@ -0,0 +1,13 @@ + + + + + OAuth landing page + + + + Shouldn't see this + + diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js new file mode 100644 index 000000000..b5d17d598 --- /dev/null +++ b/frontend/src/polyfills.js @@ -0,0 +1,41 @@ +/* eslint no-empty-function: 0 no-extend-native: 0 */ + +window.console = window.console || {}; +window.console.log = window.console.log || function() {}; +window.console.group = window.console.group || function() {}; +window.console.groupEnd = window.console.groupEnd || function() {}; +window.console.debug = window.console.debug || function() {}; +window.console.warn = window.console.warn || function() {}; +window.console.assert = window.console.assert || function() {}; + +if (!String.prototype.startsWith) { + Object.defineProperty(String.prototype, 'startsWith', { + enumerable: false, + configurable: false, + writable: false, + value(searchString, position) { + position = position || 0; + return this.indexOf(searchString, position) === position; + } + }); +} + +if (!String.prototype.endsWith) { + Object.defineProperty(String.prototype, 'endsWith', { + enumerable: false, + configurable: false, + writable: false, + value(searchString, position) { + position = position || this.length; + position = position - searchString.length; + const lastIndex = this.lastIndexOf(searchString); + return lastIndex !== -1 && lastIndex === position; + } + }); +} + +if (!('contains' in String.prototype)) { + String.prototype.contains = function(str, startIndex) { + return String.prototype.indexOf.call(this, str, startIndex) !== -1; + }; +} diff --git a/frontend/src/preload.js b/frontend/src/preload.js new file mode 100644 index 000000000..91708840f --- /dev/null +++ b/frontend/src/preload.js @@ -0,0 +1,4 @@ +/* eslint no-undef: 0 */ +import 'Shims/jquery'; + +__webpack_public_path__ = `${window.Radarr.urlBase}/`; diff --git a/frontend/src/vendor.js b/frontend/src/vendor.js new file mode 100644 index 000000000..2b08817be --- /dev/null +++ b/frontend/src/vendor.js @@ -0,0 +1,5 @@ +/* Base */ +// require('jquery'); +require('lodash'); +require('moment'); +// require('signalR');